diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6ec071be2fc9..17a371079ad3 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -21,7 +21,7 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2024.802.0", + "version": "2025.1208.0", "commands": [ "localisation" ] diff --git a/.github/workflows/update-web-mod-definitions.yml b/.github/workflows/update-web-mod-definitions.yml index b19f03ad7dd3..160872a9a114 100644 --- a/.github/workflows/update-web-mod-definitions.yml +++ b/.github/workflows/update-web-mod-definitions.yml @@ -38,8 +38,12 @@ jobs: run: ./UseLocalOsu.sh working-directory: ./osu-tools + - name: Build tools + run: dotnet build PerformanceCalculator --nologo --verbosity quiet + working-directory: ./osu-tools + - name: Regenerate mod definitions - run: dotnet run --project PerformanceCalculator -- mods > ../osu-web/database/mods.json + run: dotnet run --project PerformanceCalculator --no-build -- mods > ../osu-web/database/mods.json working-directory: ./osu-tools - name: Create pull request with changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebe1e0807477..347e0f558a09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,9 +55,7 @@ When in doubt, it's probably best to start with a discussion first. We will esca While pull requests from unaffiliated contributors are welcome, please note that due to significant community interest and limited review throughput, the core team's primary focus is on the issues which are currently [on the roadmap](https://github.com/orgs/ppy/projects/7/views/6). Reviewing PRs that fall outside of the scope of the roadmap is done on a best-effort basis, so please be aware that it may take a while before a core maintainer gets around to review your change. -The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. We also have a [`good first issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label, although from experience it is not used very often, as it is relatively rare that we can spot an issue that will definitively be a good first issue for a new contributor regardless of their programming experience. - -In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive. +The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive. If you'd like to propose a subjective change to one of the visual aspects of the game, or there is a bigger task you'd like to work on, but there is no corresponding issue or discussion thread yet for it, **please open a discussion or issue first** to avoid wasted effort. This in particular applies if you want to work on [one of the available designs from the osu! Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library). @@ -73,6 +71,9 @@ Aside from the above, below is a brief checklist of things to watch out when you After you're done with your changes and you wish to open the PR, please observe the following recommendations: - Please submit the pull request from a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary. +- Please pick the following target branch for your pull request: + - `pp-dev`, if the change impacts star rating or performance points calculations for any of the rulesets, + - `master`, otherwise. - Please avoid pushing untested or incomplete code. - Please do not force-push or rebase unless we ask you to. - Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change is ready for merge. 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 86f73a37d4d2..4fa8b9409900 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 @@ -10,8 +10,8 @@ - - + + diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs index 312d3d5e9a64..c7851cc12f02 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs @@ -19,13 +19,13 @@ public EmptyFreeformDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap b { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } 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 51c0233942c7..bb95c675fcf8 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 @@ -10,8 +10,8 @@ - - + + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6addab279e7..852576958446 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -19,13 +19,13 @@ public PippidonDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatma { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } 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 ed4e8631eafa..fc170b2c24e3 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 @@ -10,8 +10,8 @@ - - + + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs index a4dc1762d520..17139218a52d 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs @@ -19,13 +19,13 @@ public EmptyScrollingDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } 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 51c0233942c7..bb95c675fcf8 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 @@ -10,8 +10,8 @@ - - + + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6addab279e7..852576958446 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -19,13 +19,13 @@ public PippidonDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatma { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } diff --git a/osu.Android.props b/osu.Android.props index 8917bc9339f8..85c0378eced0 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + - - - - + android:roundIcon="@mipmap/ic_launcher"> + + + + diff --git a/osu.Android/Resources/xml/filepaths.xml b/osu.Android/Resources/xml/filepaths.xml new file mode 100644 index 000000000000..a2356c4aabf4 --- /dev/null +++ b/osu.Android/Resources/xml/filepaths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 885ee0620eed..2530f34939b4 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -146,9 +146,13 @@ public override void SetHost(GameHost host) { base.SetHost(host); - var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); - if (iconStream != null) - host.Window.SetIconFromStream(iconStream); + // Apple operating systems use a better icon provided via external assets. + if (!RuntimeInfo.IsApple) + { + var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); + if (iconStream != null) + host.Window.SetIconFromStream(iconStream); + } host.Window.Title = Name; } diff --git a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs index 4b6ebc9b5675..1ac0a8153a3c 100644 --- a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs +++ b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -32,7 +33,7 @@ private partial class ElevatedPrivilegesNotification : SimpleNotification { 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."; + Text = NotificationsStrings.ElevatedPrivileges(RuntimeInfo.IsUnix ? "root" : "Administrator"); } [BackgroundDependencyLoader] diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index b0c5c953d43c..bd0f4448fc8e 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/osu.Game.Benchmarks/BenchmarkCarouselFilter.cs b/osu.Game.Benchmarks/BenchmarkCarouselFilter.cs index 8f7027da1772..ecd76872d184 100644 --- a/osu.Game.Benchmarks/BenchmarkCarouselFilter.cs +++ b/osu.Game.Benchmarks/BenchmarkCarouselFilter.cs @@ -5,7 +5,7 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Carousel; +using osu.Game.Tests.NonVisual.Filtering; namespace osu.Game.Benchmarks { @@ -42,7 +42,7 @@ public class BenchmarkCarouselFilter : BenchmarkTest Status = BeatmapOnlineStatus.Loved }; - private CarouselBeatmap carouselBeatmap = null!; + private FilterMatchingTest.CarouselBeatmap carouselBeatmap = null!; private FilterCriteria criteria1 = null!; private FilterCriteria criteria2 = null!; private FilterCriteria criteria3 = null!; @@ -55,7 +55,7 @@ public override void SetUp() var beatmap = getExampleBeatmap(); beatmap.OnlineID = 20201010; beatmap.BeatmapSet = new BeatmapSetInfo { OnlineID = 1535 }; - carouselBeatmap = new CarouselBeatmap(beatmap); + carouselBeatmap = new FilterMatchingTest.CarouselBeatmap(beatmap); criteria1 = new FilterCriteria(); criteria2 = new FilterCriteria { diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index 8a353eb2f502..104ee50fe105 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -7,9 +7,9 @@ - - - + + + diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist index f87043e1d1be..a3b9dda48c2d 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist @@ -35,11 +35,9 @@ UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft - XSAppIconAssets - Assets.xcassets/AppIcon.appiconset UIApplicationSupportsIndirectInputEvents CADisableMinimumFrameDurationOnPhone - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs index 74b02bab9b55..3a0dea3e8ce5 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.IO.Stores; using osu.Game.Rulesets.Catch.Skinning; using osu.Game.Rulesets.Catch.Skinning.Legacy; @@ -21,9 +22,9 @@ public void TestCatchSkinColourDecoding() var skinSource = new SkinProvidingContainer(rawSkin); var skin = new CatchLegacySkinTransformer(skinSource); - Assert.AreEqual(new Color4(232, 185, 35, 255), skin.GetConfig(CatchSkinColour.HyperDash)?.Value); - Assert.AreEqual(new Color4(232, 74, 35, 255), skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value); - Assert.AreEqual(new Color4(0, 255, 255, 255), skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value); + ClassicAssert.AreEqual(new Color4(232, 185, 35, 255), skin.GetConfig(CatchSkinColour.HyperDash)?.Value); + ClassicAssert.AreEqual(new Color4(232, 74, 35, 255), skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value); + ClassicAssert.AreEqual(new Color4(0, 255, 255, 255), skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value); } private class TestLegacySkin : LegacySkin 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 fc1b13f3ad18..132bc4bed1f3 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 @@ -1,9 +1,9 @@  - - - + + + WinExe diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 02d266228ad7..eb8cf137faf7 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -176,15 +176,20 @@ public override IEnumerable GetModsFor(ModType type) public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetCatch }; - protected override IEnumerable GetValidHitResults() + public override IEnumerable GetValidHitResults() { return new[] { HitResult.Great, + HitResult.Miss, HitResult.LargeTickHit, + HitResult.LargeTickMiss, HitResult.SmallTickHit, + HitResult.SmallTickMiss, HitResult.LargeBonus, + HitResult.IgnoreHit, + HitResult.IgnoreMiss, }; } @@ -300,7 +305,7 @@ public override IEnumerable GetBeatmapAttributesForDisp Description = "Affects how early fruits fade in on the screen.", AdditionalMetrics = [ - new RulesetBeatmapAttribute.AdditionalMetric("Fade-in time", LocalisableString.Interpolate($@"{IBeatmapDifficultyInfo.DifficultyRange(effectiveDifficulty.ApproachRate, CatchHitObject.PREEMPT_RANGE):#,0.##} ms")) + new RulesetBeatmapAttribute.AdditionalMetric("Fade-in time", LocalisableString.Interpolate($@"{IBeatmapDifficultyInfo.DifficultyRangeInt(effectiveDifficulty.ApproachRate, CatchHitObject.PREEMPT_RANGE):#,0.##} ms")) ] }; yield return new RulesetBeatmapAttribute(SongSelectStrings.HPDrain, @"HP", originalDifficulty.DrainRate, effectiveDifficulty.DrainRate, 10) diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index dd69b5de128a..75db566009da 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Rulesets.Catch.Difficulty { @@ -22,8 +23,6 @@ public class CatchDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 4.59; - private float halfCatcherWidth; - public override int Version => 20251020; public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) @@ -31,7 +30,7 @@ public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new CatchDifficultyAttributes { Mods = mods }; @@ -46,12 +45,19 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat return attributes; } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { CatchHitObject? lastObject = null; List objects = new List(); + double clockRate = ModUtils.CalculateRateWithMods(mods); + + float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; + + // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. + halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f); + // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects)) { @@ -68,16 +74,11 @@ protected override IEnumerable CreateDifficultyHitObjects(I return objects; } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { - halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; - - // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. - halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f); - return new Skill[] { - new Movement(mods, halfCatcherWidth, clockRate), + new Movement(mods), }; } diff --git a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs index 618b18394341..8c44cd35693c 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs @@ -11,12 +11,16 @@ public static class MovementEvaluator { private const double direction_change_bonus = 21.0; - public static double EvaluateDifficultyOf(DifficultyHitObject current, double catcherSpeedMultiplier) + public static double EvaluateDifficultyOf(DifficultyHitObject current) { var catchCurrent = (CatchDifficultyHitObject)current; var catchLast = (CatchDifficultyHitObject)current.Previous(0); var catchLastLast = (CatchDifficultyHitObject)current.Previous(1); + // In catch, clockrate adjustments do not only affect the timings of hitobjects, + // but also the speed of the player's catcher, which has an impact on difficulty + double catcherSpeedMultiplier = current.ClockRate; + double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510); diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index 90055b9aa384..332ef7e17bf9 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -17,28 +17,14 @@ public class Movement : StrainDecaySkill protected override int SectionLength => 750; - protected readonly float HalfCatcherWidth; - - /// - /// The speed multiplier applied to the player's catcher. - /// - private readonly double catcherSpeedMultiplier; - - public Movement(Mod[] mods, float halfCatcherWidth, double clockRate) + public Movement(Mod[] mods) : base(mods) { - HalfCatcherWidth = halfCatcherWidth; - - // In catch, clockrate adjustments do not only affect the timings of hitobjects, - // but also the speed of the player's catcher, which has an impact on difficulty - // TODO: Support variable clockrates caused by mods such as ModTimeRamp - // (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing) - catcherSpeedMultiplier = clockRate; } protected override double StrainValueOf(DifficultyHitObject current) { - return MovementEvaluator.EvaluateDifficultyOf(current, catcherSpeedMultiplier); + return MovementEvaluator.EvaluateDifficultyOf(current); } } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs index 971c98cafd69..bd5886cb8289 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs @@ -19,7 +19,7 @@ public partial class BananaShowerPlacementBlueprint : CatchPlacementBlueprint Precision.DefinitelyBigger(HitObject.Duration, 0); + protected override bool IsValidForPlacement => base.IsValidForPlacement && (PlacementActive == PlacementState.Waiting || Precision.DefinitelyBigger(HitObject.Duration, 0)); public BananaShowerPlacementBlueprint() { diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs index 292175353a33..cce3b93d902d 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs @@ -25,7 +25,7 @@ public partial class JuiceStreamPlacementBlueprint : CatchPlacementBlueprint Precision.DefinitelyBigger(HitObject.Duration, 0); + protected override bool IsValidForPlacement => base.IsValidForPlacement && (PlacementActive == PlacementState.Waiting || Precision.DefinitelyBigger(HitObject.Duration, 0)); public JuiceStreamPlacementBlueprint() { diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index 370eb37d16bb..be9685ce9af8 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using osu.Framework.Allocation; @@ -224,7 +225,8 @@ private void updateDistanceSnapGrid() #region Clipboard handling public override string ConvertSelectionToString() - => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => (h.IndexInCurrentCombo + 1).ToString())); + => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime) + .Select(h => (h.IndexInCurrentCombo + 1).ToString(CultureInfo.InvariantCulture))); // 1,2,3,4 ... private static readonly Regex selection_regex = new Regex(@"^\d+(,\d+)*$", RegexOptions.Compiled); diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index 41deaa0d82f4..2f186f5ab4ce 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -150,7 +150,7 @@ protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, I { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, PREEMPT_RANGE); + TimePreempt = IBeatmapDifficultyInfo.DifficultyRangeInt(difficulty.ApproachRate, PREEMPT_RANGE); Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize); } diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs index 4f9048b988b1..4704e83b7684 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs @@ -72,6 +72,9 @@ public CatchLegacySkinTransformer(ISkin skin) leaderboard.Origin = Anchor.CentreLeft; leaderboard.X = 10; } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { Children = new Drawable[] diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist index 740036309fe1..83cb8f2e8ccd 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist @@ -35,11 +35,9 @@ UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft - XSAppIconAssets - Assets.xcassets/AppIcon.appiconset UIApplicationSupportsIndirectInputEvents CADisableMinimumFrameDurationOnPhone - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteTailDrag.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteTailDrag.cs new file mode 100644 index 000000000000..bdbf24bb95b3 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteTailDrag.cs @@ -0,0 +1,351 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; +using DragArea = osu.Game.Screens.Edit.Compose.Components.Timeline.TimelineHitObjectBlueprint.DragArea; + +namespace osu.Game.Rulesets.Mania.Tests.Editor +{ + public partial class TestSceneHoldNoteTailDrag : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new ManiaRuleset(); + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("Clear objects", () => EditorBeatmap.Clear()); + } + + [Test] + public void TestSimpleTailDragForward() + { + AddStep("Add hold note", () => + { + EditorBeatmap.Add(new HoldNote { StartTime = 2170, Duration = 937.5 }); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().Single(); + dragForward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Duration is higher", () => ((HoldNote)EditorBeatmap.HitObjects.First())!.Duration > 937.5f); + } + + [Test] + public void TestSimpleTailDragBackwards() + { + AddStep("Add hold note", () => + { + EditorBeatmap.Add(new HoldNote { StartTime = 2170, Duration = 937.5 }); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().Single(); + dragBackward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Duration is lower", () => ((HoldNote)EditorBeatmap.HitObjects[0]).Duration < 937.5f); + } + + [Test] + public void TestSamePositionButNotSelectedDragForward() + { + AddStep("Add hold notes", () => + { + EditorBeatmap.AddRange([ + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 0 }, + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 1 } + ]); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + dragForward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Duration is higher, other is unchanged", () => + ((HoldNote)EditorBeatmap.HitObjects[0]).Duration > 937.5f && + ((HoldNote)EditorBeatmap.HitObjects[^1]).Duration == 937.5f + ); + } + + [Test] + public void TestSamePositionButNotSelectedDragBackward() + { + AddStep("Add hold notes", () => + { + EditorBeatmap.AddRange([ + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 0 }, + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 1 } + ]); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + dragBackward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Duration is lower, other is unchanged", () => + ((HoldNote)EditorBeatmap.HitObjects[0]).Duration < 937.5f && + ((HoldNote)EditorBeatmap.HitObjects[^1]).Duration == 937.5f + ); + } + + [Test] + public void TestSamePositionSelectedDragForward() + { + AddStep("Add hold notes", () => + { + EditorBeatmap.AddRange([ + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 0 }, + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 1 } + ]); + }); + + AddStep("Select all", () => + { + EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + dragForward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Both durations are higher", () => + ((HoldNote)EditorBeatmap.HitObjects[0]).Duration > 937.5f && + ((HoldNote)EditorBeatmap.HitObjects[^1]).Duration > 937.5f + ); + } + + [Test] + public void TestSamePositionSelectedDragBackward() + { + AddStep("Add hold notes", () => + { + EditorBeatmap.AddRange([ + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 0 }, + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 1 } + ]); + }); + + AddStep("Select all", () => + { + EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + dragBackward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Both durations are lower", () => + ((HoldNote)EditorBeatmap.HitObjects[0]).Duration < 937.5f && + ((HoldNote)EditorBeatmap.HitObjects[^1]).Duration < 937.5f + ); + } + + [Test] + public void TestSelectedButDifferentPositions() + { + AddStep("Add hold notes", () => + { + EditorBeatmap.AddRange([ + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 0 }, + new HoldNote { StartTime = 2404, Duration = 937.5, Column = 1 } + ]); + }); + + AddStep("Select all", () => + { + EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + dragBackward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Duration is unchanged, other is lower", () => + ((HoldNote)EditorBeatmap.HitObjects[0]).Duration == 937.5f && + ((HoldNote)EditorBeatmap.HitObjects[^1]).Duration < 937.5f + ); + } + + [Test] + public void TestSelectedSameStartTimeDifferentDurations() + { + AddStep("Add hold notes", () => + { + EditorBeatmap.AddRange([ + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 0 }, + new HoldNote { StartTime = 2170, Duration = 1171.8, Column = 1 } + ]); + }); + + AddStep("Select all", () => + { + EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects); + }); + + AddStep("Drag until both match", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(blueprintDragArea); + InputManager.PressKey(Key.LShift); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(new Vector2(1000, 110)); + }); + + AddStep("Continue the drag", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + dragBackward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Duration is unchanged, other is lower", () => + ((HoldNote)EditorBeatmap.HitObjects[0]).Duration == 937.5f && + ((HoldNote)EditorBeatmap.HitObjects[^1]).Duration < 937.5f + ); + } + + [Test] + public void TestSelectedSameDurationDifferentStartTimes() + { + AddStep("Add hold notes", () => + { + EditorBeatmap.AddRange([ + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 0 }, + new HoldNote { StartTime = 2638.7, Duration = 937.5, Column = 1 } + ]); + }); + + AddStep("Select all", () => + { + EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + dragBackward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Duration is unchanged, other is lower", () => + ((HoldNote)EditorBeatmap.HitObjects[0]).Duration == 937.5f && + ((HoldNote)EditorBeatmap.HitObjects[^1]).Duration < 937.5f + ); + } + + [Test] + public void TestDragNoteOutsideOfSelection() + { + AddStep("Add hold notes", () => + { + EditorBeatmap.AddRange([ + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 0 }, + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 1 } + ]); + }); + + AddStep("Select the back stack slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.Last()); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + dragBackward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Duration is lower, other is unchanged", () => + ((HoldNote)EditorBeatmap.HitObjects[0]).Duration < 937.5f && + ((HoldNote)EditorBeatmap.HitObjects[^1]).Duration == 937.5f + ); + } + + [Test] + public void TestDragHoldNoteWithNotes() + { + AddStep("Add notes", () => + { + EditorBeatmap.AddRange([ + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 0 }, + new Note { StartTime = 2170, Column = 1 }, + new Note { StartTime = 3107.5, Column = 2 }, + new HoldNote { StartTime = 2170, Duration = 937.5, Column = 3 } + ]); + }); + + AddStep("Select all", () => + { + EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects); + }); + + AddStep("Drag tail", () => + { + var blueprintDragArea = this.ChildrenOfType().First(); + dragBackward(blueprintDragArea); + }); + + AddStep("Release tail", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("Both durations are lower", () => + { + var holdNotes = EditorBeatmap.HitObjects.OfType(); + return holdNotes.First().Duration < 937.5f && holdNotes.Last().Duration < 937.5f; + } + ); + } + + private void dragForward(DragArea dragArea) + { + InputManager.MoveMouseTo(dragArea); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(new Vector2(1100, 110)); + } + + private void dragBackward(DragArea dragArea) + { + InputManager.MoveMouseTo(dragArea); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(new Vector2(700, 110)); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs index 0cb9639cd177..a18b7652337f 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs @@ -5,6 +5,7 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; using osu.Game.Rulesets.Edit; @@ -16,6 +17,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; using osu.Game.Tests.Visual; using osuTK.Input; @@ -36,29 +38,31 @@ public void Setup() => Schedule(() => [Test] public void TestPlaceBeforeCurrentTimeDownwards() { + AddStep("seek to 200", () => HitObjectContainer.Dependencies.Get().Seek(200)); AddStep("move mouse before current time", () => { var column = this.ChildrenOfType().Single(); - InputManager.MoveMouseTo(column.ScreenSpacePositionAtTime(-100)); + InputManager.MoveMouseTo(column.ScreenSpacePositionAtTime(100)); }); AddStep("click", () => InputManager.Click(MouseButton.Left)); - AddAssert("note start time < 0", () => getNote().StartTime < 0); + AddAssert("note start time < 200", () => getNote().StartTime < 200); } [Test] public void TestPlaceAfterCurrentTimeDownwards() { + AddStep("seek to 200", () => HitObjectContainer.Dependencies.Get().Seek(200)); AddStep("move mouse after current time", () => { var column = this.ChildrenOfType().Single(); - InputManager.MoveMouseTo(column.ScreenSpacePositionAtTime(100)); + InputManager.MoveMouseTo(column.ScreenSpacePositionAtTime(300)); }); AddStep("click", () => InputManager.Click(MouseButton.Left)); - AddAssert("note start time > 0", () => getNote().StartTime > 0); + AddAssert("note start time > 200", () => getNote().StartTime > 200); } private Note getNote() => this.ChildrenOfType().FirstOrDefault()?.HitObject; diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneObjectPlacement.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneObjectPlacement.cs index 13a116b20918..94b832e43ad9 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneObjectPlacement.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneObjectPlacement.cs @@ -42,7 +42,7 @@ public void TestSeekOnNotePlacement() AddStep("change seek setting to true", () => config.SetValue(OsuSetting.EditorAutoSeekOnPlacement, true)); placeObject(); AddUntilStep("wait for seek to complete", () => !EditorClock.IsSeeking); - AddAssert("seeked forward to object", () => EditorClock.CurrentTime, () => Is.GreaterThan(initialTime)); + AddAssert("seeked forward to object", () => EditorClock.CurrentTime, () => Is.GreaterThan(initialTime!)); } [Test] diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneOpenEditorTimestampInMania.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneOpenEditorTimestampInMania.cs index 05c881d284e9..ad41ad9be470 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneOpenEditorTimestampInMania.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneOpenEditorTimestampInMania.cs @@ -18,15 +18,11 @@ public partial class TestSceneOpenEditorTimestampInMania : EditorTestScene public void TestNormalSelection() { addStepClickLink("00:05:920 (5920|3,6623|3,6857|2,7326|1)"); - AddAssert("selected group", () => checkSnapAndSelectColumn(5_920, new List<(int, int)> - { (5_920, 3), (6_623, 3), (6_857, 2), (7_326, 1) } - )); + AddAssert("selected group", () => checkSnapAndSelectColumn(5_920, [(5_920, 3), (6_623, 3), (6_857, 2), (7_326, 1)])); addReset(); addStepClickLink("00:42:716 (42716|3,43420|2,44123|0,44357|1,45295|1)"); - AddAssert("selected ungrouped", () => checkSnapAndSelectColumn(42_716, new List<(int, int)> - { (42_716, 3), (43_420, 2), (44_123, 0), (44_357, 1), (45_295, 1) } - )); + AddAssert("selected ungrouped", () => checkSnapAndSelectColumn(42_716, [(42_716, 3), (43_420, 2), (44_123, 0), (44_357, 1), (45_295, 1)])); addReset(); AddStep("add notes to row", () => @@ -41,15 +37,20 @@ public void TestNormalSelection() EditorBeatmap.AddRange(new[] { second, third, forth }); }); addStepClickLink("00:11:545 (11545|0,11545|1,11545|2,11545|3)"); - AddAssert("selected in row", () => checkSnapAndSelectColumn(11_545, new List<(int, int)> - { (11_545, 0), (11_545, 1), (11_545, 2), (11_545, 3) } - )); + AddAssert("selected in row", () => checkSnapAndSelectColumn(11_545, [(11_545, 0), (11_545, 1), (11_545, 2), (11_545, 3)])); addReset(); addStepClickLink("01:36:623 (96623|1,97560|1,97677|1,97795|1,98966|1)"); - AddAssert("selected in column", () => checkSnapAndSelectColumn(96_623, new List<(int, int)> - { (96_623, 1), (97_560, 1), (97_677, 1), (97_795, 1), (98_966, 1) } - )); + AddAssert("selected in column", () => checkSnapAndSelectColumn(96_623, [(96_623, 1), (97_560, 1), (97_677, 1), (97_795, 1), (98_966, 1)])); + } + + [Test] + public void TestRoundingToNearestMillisecondApplied() + { + AddStep("resnap note to have fractional coordinates", + () => EditorBeatmap.HitObjects.OfType().Single(ho => ho.StartTime == 85_373 && ho.Column == 1).StartTime = 85_373.125); + addStepClickLink("01:25:373 (85373|1)"); + AddAssert("selected note", () => checkSnapAndSelectColumn(85_373.125, [(85_373.125, 1)])); } [Test] @@ -75,7 +76,7 @@ private void addStepClickLink(string timestamp, string step = "", bool displayTi private void addReset() => addStepClickLink("00:00:000", "reset", false); - private bool checkSnapAndSelectColumn(double startTime, IReadOnlyCollection<(int, int)>? columnPairs = null) + private bool checkSnapAndSelectColumn(double startTime, IReadOnlyCollection<(double, int)>? columnPairs = null) { bool checkColumns = columnPairs != null ? EditorBeatmap.SelectedHitObjects.All(x => columnPairs.Any(col => isNoteAt(x, col.Item1, col.Item2))) diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs index b4f084a07c9a..823538919b60 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs @@ -24,6 +24,7 @@ public class ManiaBeatmapSampleConversionTest : BeatmapConversionTest base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaFilterCriteriaTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaFilterCriteriaTest.cs index ad3cf4e05f22..49fb8ecb2ce6 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaFilterCriteriaTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaFilterCriteriaTest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Mods; using osu.Game.Screens.Select; @@ -18,19 +19,19 @@ public void TestKeysEqualSingleValue() var criteria = new ManiaFilterCriteria(); criteria.TryParseCustomKeywordCriteria("keys", Operator.Equal, "1"); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }), new FilterCriteria())); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }), new FilterCriteria())); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 4 }), new FilterCriteria { @@ -44,19 +45,19 @@ public void TestKeysEqualMultipleValues() var criteria = new ManiaFilterCriteria(); criteria.TryParseCustomKeywordCriteria("keys", Operator.Equal, "1,3,5,7"); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }), new FilterCriteria())); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 4 }), new FilterCriteria { @@ -70,19 +71,19 @@ public void TestKeysNotEqualSingleValue() var criteria = new ManiaFilterCriteria(); criteria.TryParseCustomKeywordCriteria("keys", Operator.NotEqual, "1"); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }), new FilterCriteria())); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 4 }), new FilterCriteria { @@ -96,19 +97,19 @@ public void TestKeysNotEqualMultipleValues() var criteria = new ManiaFilterCriteria(); criteria.TryParseCustomKeywordCriteria("keys", Operator.NotEqual, "1,3,5,7"); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }), new FilterCriteria())); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }), new FilterCriteria())); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 4 }), new FilterCriteria { @@ -122,23 +123,23 @@ public void TestKeysGreaterOrEqualThan() var criteria = new ManiaFilterCriteria(); criteria.TryParseCustomKeywordCriteria("keys", Operator.GreaterOrEqual, "4"); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 1 }), new FilterCriteria())); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 2 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 4 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 5 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new RulesetInfo { OnlineID = 0 }, new BeatmapDifficulty { CircleSize = 3 }), new FilterCriteria { @@ -153,23 +154,23 @@ public void TestKeysFilterIntersection() criteria.TryParseCustomKeywordCriteria("keys", Operator.Greater, "4"); criteria.TryParseCustomKeywordCriteria("keys", Operator.NotEqual, "7"); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 3 }), new FilterCriteria())); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 4 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 5 }), new FilterCriteria())); - Assert.False(criteria.Matches( + ClassicAssert.False(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 7 }), new FilterCriteria())); - Assert.True(criteria.Matches( + ClassicAssert.True(criteria.Matches( new BeatmapInfo(new ManiaRuleset().RulesetInfo, new BeatmapDifficulty { CircleSize = 9 }), new FilterCriteria())); } @@ -179,9 +180,9 @@ public void TestInvalidKeysFilters() { var criteria = new ManiaFilterCriteria(); - Assert.False(criteria.TryParseCustomKeywordCriteria("keys", Operator.Equal, "some text")); - Assert.False(criteria.TryParseCustomKeywordCriteria("keys", Operator.NotEqual, "4,some text")); - Assert.False(criteria.TryParseCustomKeywordCriteria("keys", Operator.GreaterOrEqual, "4,5,6")); + ClassicAssert.False(criteria.TryParseCustomKeywordCriteria("keys", Operator.Equal, "some text")); + ClassicAssert.False(criteria.TryParseCustomKeywordCriteria("keys", Operator.NotEqual, "4,some text")); + ClassicAssert.False(criteria.TryParseCustomKeywordCriteria("keys", Operator.GreaterOrEqual, "4,5,6")); } [TestCase] @@ -199,7 +200,7 @@ public void TestLnsEqual() TotalObjectCount = 0, EndTimeObjectCount = 0 }; - Assert.True(criteria.Matches(beatmapInfo1, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo1, filterCriteria)); criteria.TryParseCustomKeywordCriteria("lns", Operator.Equal, "0"); BeatmapInfo beatmapInfo2 = new BeatmapInfo(new ManiaRuleset().RulesetInfo) @@ -207,7 +208,7 @@ public void TestLnsEqual() TotalObjectCount = 100, EndTimeObjectCount = 0 }; - Assert.True(criteria.Matches(beatmapInfo2, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo2, filterCriteria)); criteria.TryParseCustomKeywordCriteria("lns", Operator.Equal, "100"); BeatmapInfo beatmapInfo3 = new BeatmapInfo(new ManiaRuleset().RulesetInfo) @@ -215,7 +216,7 @@ public void TestLnsEqual() TotalObjectCount = 100, EndTimeObjectCount = 100 }; - Assert.True(criteria.Matches(beatmapInfo3, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo3, filterCriteria)); criteria.TryParseCustomKeywordCriteria("lns", Operator.Equal, "1"); BeatmapInfo beatmapInfo4 = new BeatmapInfo(new ManiaRuleset().RulesetInfo) @@ -223,7 +224,7 @@ public void TestLnsEqual() TotalObjectCount = 100, EndTimeObjectCount = 1 }; - Assert.True(criteria.Matches(beatmapInfo4, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo4, filterCriteria)); criteria.TryParseCustomKeywordCriteria("lns", Operator.Equal, "0.1"); BeatmapInfo beatmapInfo5 = new BeatmapInfo(new ManiaRuleset().RulesetInfo) @@ -231,7 +232,7 @@ public void TestLnsEqual() TotalObjectCount = 1000, EndTimeObjectCount = 1 }; - Assert.True(criteria.Matches(beatmapInfo5, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo5, filterCriteria)); } [TestCase] @@ -249,7 +250,7 @@ public void TestLnsGreaterOrEqual() TotalObjectCount = 0, EndTimeObjectCount = 0 }; - Assert.True(criteria.Matches(beatmapInfo1, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo1, filterCriteria)); criteria.TryParseCustomKeywordCriteria("lns", Operator.GreaterOrEqual, "0"); BeatmapInfo beatmapInfo2 = new BeatmapInfo(new ManiaRuleset().RulesetInfo) @@ -257,7 +258,7 @@ public void TestLnsGreaterOrEqual() TotalObjectCount = 100, EndTimeObjectCount = 0 }; - Assert.True(criteria.Matches(beatmapInfo2, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo2, filterCriteria)); criteria.TryParseCustomKeywordCriteria("lns", Operator.GreaterOrEqual, "100"); BeatmapInfo beatmapInfo3 = new BeatmapInfo(new ManiaRuleset().RulesetInfo) @@ -265,7 +266,7 @@ public void TestLnsGreaterOrEqual() TotalObjectCount = 100, EndTimeObjectCount = 100 }; - Assert.True(criteria.Matches(beatmapInfo3, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo3, filterCriteria)); criteria.TryParseCustomKeywordCriteria("lns", Operator.GreaterOrEqual, "1"); BeatmapInfo beatmapInfo4 = new BeatmapInfo(new ManiaRuleset().RulesetInfo) @@ -273,7 +274,7 @@ public void TestLnsGreaterOrEqual() TotalObjectCount = 100, EndTimeObjectCount = 1 }; - Assert.True(criteria.Matches(beatmapInfo4, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo4, filterCriteria)); criteria.TryParseCustomKeywordCriteria("lns", Operator.GreaterOrEqual, "0.1"); BeatmapInfo beatmapInfo5 = new BeatmapInfo(new ManiaRuleset().RulesetInfo) @@ -281,7 +282,7 @@ public void TestLnsGreaterOrEqual() TotalObjectCount = 1000, EndTimeObjectCount = 1 }; - Assert.True(criteria.Matches(beatmapInfo5, filterCriteria)); + ClassicAssert.True(criteria.Matches(beatmapInfo5, filterCriteria)); } [TestCase] @@ -299,7 +300,7 @@ public void TestLnsNotManiaRuleset() TotalObjectCount = 100, EndTimeObjectCount = 50 }; - Assert.False(criteria.Matches(beatmapInfo, filterCriteria)); + ClassicAssert.False(criteria.Matches(beatmapInfo, filterCriteria)); } [TestCase] @@ -307,8 +308,8 @@ public void TestInvalidLnsFilters() { var criteria = new ManiaFilterCriteria(); - Assert.False(criteria.TryParseCustomKeywordCriteria("lns", Operator.Equal, "some text")); - Assert.False(criteria.TryParseCustomKeywordCriteria("lns", Operator.GreaterOrEqual, "1some text")); + ClassicAssert.False(criteria.TryParseCustomKeywordCriteria("lns", Operator.Equal, "some text")); + ClassicAssert.False(criteria.TryParseCustomKeywordCriteria("lns", Operator.GreaterOrEqual, "1some text")); } } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs index ff1f9e68940d..726d0af945a0 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using osu.Game.Rulesets.Mania.Beatmaps; using NUnit.Framework; +using NUnit.Framework.Legacy; namespace osu.Game.Rulesets.Mania.Tests { @@ -35,7 +36,7 @@ public void Test(IEnumerable special, int columns) { var definition = new StageDefinition(columns); var results = getResults(definition); - Assert.AreEqual(special, results); + ClassicAssert.AreEqual(special, results); } private IEnumerable getResults(StageDefinition definition) diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs index f5117b61af5f..a06d389f957b 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Mods; using osu.Game.Tests.Visual; @@ -20,7 +21,7 @@ public partial class TestSceneManiaModHoldOff : ModTestScene public void TestMapHasNoHoldNotes() { var testBeatmap = createModdedBeatmap(); - Assert.False(testBeatmap.HitObjects.OfType().Any()); + ClassicAssert.False(testBeatmap.HitObjects.OfType().Any()); } [Test] diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/convert-beatmap-custom-sample-bank.osu b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/convert-beatmap-custom-sample-bank.osu new file mode 100644 index 000000000000..bccaf49023bf --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/convert-beatmap-custom-sample-bank.osu @@ -0,0 +1,10 @@ +osu file format v14 + +[General] +Mode: 0 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,2,0:0:0:0: diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/spinner-convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/spinner-convert-samples-expected-conversion.json new file mode 100644 index 000000000000..6a4ce67ec131 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/spinner-convert-samples-expected-conversion.json @@ -0,0 +1,16 @@ +{ + "Mappings": [{ + "StartTime": 1000.0, + "Objects": [{ + "StartTime": 1000.0, + "EndTime": 8000.0, + "Column": 0, + "PlaySlidingSamples": false, + "NodeSamples": [ + ["Gameplay/soft-hitnormal"], + ["Gameplay/soft-hitnormal", "Gameplay/soft-hitfinish"] + ], + "Samples": ["Gameplay/soft-hitnormal", "Gameplay/soft-hitfinish"], + }] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/spinner-convert-samples.osu b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/spinner-convert-samples.osu new file mode 100644 index 000000000000..b68c5cc05504 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/spinner-convert-samples.osu @@ -0,0 +1,18 @@ +osu file format v14 + +[General] +Mode: 0 + +[Difficulty] +HPDrainRate:5 +CircleSize:5 +OverallDifficulty:5 +ApproachRate:5 +SliderMultiplier:1.4 +SliderTickRate:1 + +[TimingPoints] +0,500,4,2,0,100,1,0 + +[HitObjects] +256,192,1000,8,4,8000,0:2:0:0: diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs index 9a3167b97f64..fd4eb9855c06 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Testing; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; @@ -33,11 +34,11 @@ public void TestSingleNote() var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); - Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); - Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); + ClassicAssert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); + ClassicAssert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); + ClassicAssert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); } [Test] @@ -54,11 +55,11 @@ public void TestSingleHoldNote() var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); - Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); - Assert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); + ClassicAssert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); + ClassicAssert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); + ClassicAssert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); } [Test] @@ -74,11 +75,11 @@ public void TestSingleNoteChord() var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); - Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); - Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been released"); + ClassicAssert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); + ClassicAssert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); + ClassicAssert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been released"); } [Test] @@ -96,13 +97,13 @@ public void TestHoldNoteChord() var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); + ClassicAssert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); - Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); - Assert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); + ClassicAssert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); + ClassicAssert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been released"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been released"); } [Test] @@ -119,15 +120,15 @@ public void TestSingleNoteStair() var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.AreEqual(generated.Frames.Count, frame_offset + 4, "Incorrect number of frames"); - Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect first note hit time"); - Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect first note release time"); - Assert.AreEqual(2000, generated.Frames[frame_offset + 2].Time, "Incorrect second note hit time"); - Assert.AreEqual(2000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 3].Time, "Incorrect second note release time"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key2), "Key2 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 3], ManiaAction.Key2), "Key2 has not been released"); + ClassicAssert.AreEqual(generated.Frames.Count, frame_offset + 4, "Incorrect number of frames"); + ClassicAssert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect first note hit time"); + ClassicAssert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect first note release time"); + ClassicAssert.AreEqual(2000, generated.Frames[frame_offset + 2].Time, "Incorrect second note hit time"); + ClassicAssert.AreEqual(2000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 3].Time, "Incorrect second note release time"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key2), "Key2 has not been pressed"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 3], ManiaAction.Key2), "Key2 has not been released"); } [Test] @@ -146,16 +147,16 @@ public void TestHoldNoteStair() var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.AreEqual(generated.Frames.Count, frame_offset + 4, "Incorrect number of frames"); - Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect first note hit time"); - Assert.AreEqual(3000, generated.Frames[frame_offset + 2].Time, "Incorrect first note release time"); - Assert.AreEqual(2000, generated.Frames[frame_offset + 1].Time, "Incorrect second note hit time"); - Assert.AreEqual(4000, generated.Frames[frame_offset + 3].Time, "Incorrect second note release time"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key1), "Key1 has not been released"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key2), "Key2 has been released"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 3], ManiaAction.Key2), "Key2 has not been released"); + ClassicAssert.AreEqual(generated.Frames.Count, frame_offset + 4, "Incorrect number of frames"); + ClassicAssert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect first note hit time"); + ClassicAssert.AreEqual(3000, generated.Frames[frame_offset + 2].Time, "Incorrect first note release time"); + ClassicAssert.AreEqual(2000, generated.Frames[frame_offset + 1].Time, "Incorrect second note hit time"); + ClassicAssert.AreEqual(4000, generated.Frames[frame_offset + 3].Time, "Incorrect second note release time"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key1), "Key1 has not been released"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key2), "Key2 has been released"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 3], ManiaAction.Key2), "Key2 has not been released"); } [Test] @@ -173,14 +174,14 @@ public void TestHoldNoteWithReleasePress() var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.AreEqual(generated.Frames.Count, frame_offset + 3, "Incorrect number of frames"); - Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect first note hit time"); - Assert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect second note press time + first note release time"); - Assert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 2].Time, "Incorrect second note release time"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); - Assert.IsTrue(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key2), "Key2 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key2), "Key2 has not been released"); + ClassicAssert.AreEqual(generated.Frames.Count, frame_offset + 3, "Incorrect number of frames"); + ClassicAssert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect first note hit time"); + ClassicAssert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect second note press time + first note release time"); + ClassicAssert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 2].Time, "Incorrect second note release time"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); + ClassicAssert.True(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key2), "Key2 has not been pressed"); + ClassicAssert.False(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key2), "Key2 has not been released"); } private bool checkContains(ReplayFrame frame, params ManiaAction[] actions) => actions.All(action => ((ManiaReplayFrame)frame).Actions.Contains(action)); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs index 36ecbdb098f6..bbac75f74fe3 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs @@ -45,5 +45,19 @@ public void TestManiaHitObjectCustomSampleBank() AssertBeatmapLookup(expected_sample); AssertNoLookup(unwanted_sample); } + + [Test] + public void TestConvertHitObjectCustomSampleBank() + { + const string beatmap_sample = "normal-hitwhistle2"; + const string user_skin_sample = "normal-hitnormal"; + + SetupSkins(beatmap_sample, user_skin_sample); + + CreateTestWithBeatmap("convert-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(beatmap_sample); + AssertUserLookup(user_skin_sample); + } } } 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 edb01b044ed6..8d5c4d9da61b 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 @@ -1,9 +1,9 @@  - - - + + + WinExe diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/SpinnerPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/SpinnerPatternGenerator.cs index 39896d3e13ae..f2ca2888c7fb 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/SpinnerPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/SpinnerPatternGenerator.cs @@ -85,7 +85,11 @@ private void addToPattern(Pattern pattern, int column, bool holdNote) Duration = endTime - HitObject.StartTime, Column = column, Samples = HitObject.Samples, - NodeSamples = (HitObject as IHasRepeats)?.NodeSamples + NodeSamples = + [ + HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_NORMAL).ToList(), + HitObject.Samples + ] }; } else diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index bcf16e68088c..1bfa3bec6d7c 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -19,6 +19,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Mania.Difficulty { @@ -36,7 +37,7 @@ public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset); } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new ManiaDifficultyAttributes { Mods = mods }; @@ -62,11 +63,13 @@ private static int maxComboForObject(HitObject hitObject) return 1; } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { var sortedObjects = beatmap.HitObjects.ToArray(); int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns; + double clockRate = ModUtils.CalculateRateWithMods(mods); + LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); List objects = new List(); @@ -88,7 +91,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I // Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required. protected override IEnumerable SortObjects(IEnumerable input) => input; - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { new Strain(mods, ((ManiaBeatmap)Beatmap).TotalColumns) }; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index 094c59da4637..2674ab4d7502 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -26,7 +26,7 @@ public partial class HoldNotePlacementBlueprint : ManiaPlacementBlueprint Precision.DefinitelyBigger(HitObject.Duration, 0); + protected override bool IsValidForPlacement => base.IsValidForPlacement && (PlacementActive == PlacementState.Waiting || Precision.DefinitelyBigger(HitObject.Duration, 0)); public HoldNotePlacementBlueprint() : base(new HoldNote()) diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index bc20456722b4..7da501063dfe 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using osu.Framework.Allocation; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; @@ -54,7 +56,8 @@ protected override ComposeBlueprintContainer CreateBlueprintContainer() }; public override string ConvertSelectionToString() - => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}")); + => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime) + .Select(h => FormattableString.Invariant($"{Math.Round(h.StartTime)}|{h.Column}"))); // 123|0,456|1,789|2 ... private static readonly Regex selection_regex = new Regex(@"^\d+\|\d+(,\d+\|\d+)*$", RegexOptions.Compiled); @@ -73,10 +76,10 @@ public override void SelectFromTimestamp(double timestamp, string objectDescript if (split.Length != 2) continue; - if (!double.TryParse(split[0], out double time) || !int.TryParse(split[1], out int column)) + if (!int.TryParse(split[0], out int time) || !int.TryParse(split[1], out int column)) continue; - ManiaHitObject? current = remainingHitObjects.FirstOrDefault(h => h.StartTime == time && h.Column == column); + ManiaHitObject? current = remainingHitObjects.FirstOrDefault(h => Precision.AlmostEquals(h.StartTime, time, 0.5) && h.Column == column); if (current == null) continue; diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index cc64ee0d6986..3fad9d1047b6 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -383,7 +383,7 @@ private PlayfieldType getPlayfieldType(int variant) return (PlayfieldType)Enum.GetValues(typeof(PlayfieldType)).Cast().OrderDescending().First(v => variant >= v); } - protected override IEnumerable GetValidHitResults() + public override IEnumerable GetValidHitResults() { return new[] { @@ -392,9 +392,11 @@ protected override IEnumerable GetValidHitResults() HitResult.Good, HitResult.Ok, HitResult.Meh, + HitResult.Miss, - // HitResult.SmallBonus is used for awarding perfect bonus score but is not included here as - // it would be a bit redundant to show this to the user. + HitResult.IgnoreHit, + HitResult.ComboBreak, + HitResult.IgnoreMiss, }; } diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 791f46d40739..b1884aab6fa1 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -7,7 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mania.Configuration; @@ -31,47 +31,45 @@ private void load() Children = new Drawable[] { - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = RulesetSettingsStrings.ScrollingDirection, + Caption = RulesetSettingsStrings.ScrollingDirection, Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection) - }, - new SettingsSlider + }), + new SettingsItemV2(new FormSliderBar { - LabelText = RulesetSettingsStrings.ScrollSpeed, + Caption = RulesetSettingsStrings.ScrollSpeed, Current = config.GetBindable(ManiaRulesetSetting.ScrollSpeed), - KeyboardStep = 1 - }, - new SettingsCheckbox + KeyboardStep = 1, + LabelFormat = v => RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(v), v), + }), + new SettingsItemV2(new FormCheckBox { - Keywords = new[] { "color" }, - LabelText = RulesetSettingsStrings.TimingBasedColouring, + Caption = RulesetSettingsStrings.TimingBasedColouring, Current = config.GetBindable(ManiaRulesetSetting.TimingBasedNoteColouring), + }) + { + Keywords = new[] { "color" }, }, }; - Add(new SettingsCheckbox + Add(new SettingsItemV2(new FormCheckBox { - LabelText = RulesetSettingsStrings.TouchOverlay, + Caption = RulesetSettingsStrings.TouchOverlay, Current = config.GetBindable(ManiaRulesetSetting.TouchOverlay) - }); + })); if (RuntimeInfo.IsMobile) { - Add(new SettingsEnumDropdown + Add(new SettingsItemV2(new FormEnumDropdown { - LabelText = RulesetSettingsStrings.MobileLayout, + Caption = RulesetSettingsStrings.MobileLayout, Current = config.GetBindable(ManiaRulesetSetting.MobileLayout), #pragma warning disable CS0618 // Type or member is obsolete Items = Enum.GetValues().Where(l => l != ManiaMobileLayout.LandscapeWithOverlay), #pragma warning restore CS0618 // Type or member is obsolete - }); + })); } } - - private partial class ManiaScrollSlider : RoundedSliderBar - { - public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(Current.Value), Current.Value); - } } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs index 3ebfcedfd107..f51a7774a582 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs @@ -30,7 +30,7 @@ public class ManiaModCover : ManiaModWithPlayfieldCover typeof(ManiaModFadeIn) }).ToArray(); - public override bool Ranked => false; + public override bool Ranked => true; public override bool ValidForFreestyleAsRequiredMod => false; diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs index a71b8aa98267..81cc52e9259b 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -57,6 +57,9 @@ public ManiaArgonSkinTransformer(ISkin skin, IBeatmap beatmap) if (spectatorList != null) spectatorList.Position = new Vector2(36, -66); + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { new DrawableGameplayLeaderboard(), diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index f0d8430f71e6..690705664d2f 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -64,11 +64,13 @@ private static readonly IReadOnlyDictionary default_hit_resul private readonly Lazy hasKeyTexture; private readonly ManiaBeatmap beatmap; + private readonly bool isBeatmapConverted; public ManiaLegacySkinTransformer(ISkin skin, IBeatmap beatmap) : base(skin) { this.beatmap = (ManiaBeatmap)beatmap; + isBeatmapConverted = !beatmap.BeatmapInfo.Ruleset.Equals(new ManiaRuleset().RulesetInfo); isLegacySkin = new Lazy(() => GetConfig(SkinConfiguration.LegacySetting.Version) != null); hasKeyTexture = new Lazy(() => @@ -120,6 +122,9 @@ public override Drawable GetDrawableComponent(ISkinComponentLookup lookup) leaderboard.Origin = Anchor.CentreLeft; leaderboard.X = 10; } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { new LegacyManiaComboCounter(), @@ -196,8 +201,8 @@ private Drawable getResult(HitResult result) public override ISample GetSample(ISampleInfo sampleInfo) { - // layered hit sounds never play in mania - if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered) + // layered hit sounds never play in mania-native beatmaps (but do play on converts) + if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered && !isBeatmapConverted) return new SampleVirtual(); return base.GetSample(sampleInfo); diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index faa9fc318c0d..d5a92a986bab 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -154,7 +154,7 @@ public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction c var hitWindows = new ManiaHitWindows(); - AddInternal(judgementPooler = new JudgementPooler(Enum.GetValues().Where(r => hitWindows.IsHitResultAllowed(r)))); + AddInternal(judgementPooler = new JudgementPooler(Enum.GetValues().Where(hitWindows.IsHitResultAllowed))); RegisterPool(50, 200); } diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist index 7f489874e745..6f0ea841076d 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist @@ -35,11 +35,9 @@ UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft - XSAppIconAssets - Assets.xcassets/AppIcon.appiconset UIApplicationSupportsIndirectInputEvents CADisableMinimumFrameDurationOnPhone - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs index c6893a5bdfb4..b9258f0053ca 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs @@ -245,13 +245,13 @@ public void TestGridPlacementTool() AddAssert("grid spacing is distance to slider tail", () => { var composer = Editor.ChildrenOfType().Single(); - return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01) + return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.1) && Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y); }); AddAssert("grid rotation points to slider tail", () => { var composer = Editor.ChildrenOfType().Single(); - return Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01); + return Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.1); }); AddStep("start grid placement", () => InputManager.Key(Key.Number5)); @@ -280,9 +280,9 @@ public void TestGridPlacementTool() AddAssert("grid spacing and rotation unchanged", () => { var composer = Editor.ChildrenOfType().Single(); - return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01) + return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.1) && Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y) - && Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01); + && Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.1); }); } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs index 0e36c1dc4582..74474c0b6ecd 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs @@ -8,6 +8,7 @@ using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.UI; @@ -22,7 +23,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [TestFixture] public partial class TestSceneSliderDrawing : TestSceneOsuEditor { - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new TestBeatmap(ruleset, false); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint()); + return beatmap; + } [Test] public void TestTouchInputPlaceHitCircleDirectly() diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs index a44c16a2e03d..058776c527b7 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs @@ -3,10 +3,13 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Tests.Beatmaps; @@ -30,6 +33,16 @@ public partial class TestSceneSliderReversal : TestSceneOsuEditor PathType.LINEAR, new Vector2(100, 0), new Vector2(100, 100) + ), + createPathSegment( + PathType.PERFECT_CURVE, + new Vector2(100.009f, -50.0009f), + new Vector2(200.0089f, -100) + ), + createPathSegment( + PathType.PERFECT_CURVE, + new Vector2(25, -50), + new Vector2(100, 75) ) }; @@ -48,9 +61,13 @@ private static PathControlPoint[] createPathSegment(PathType type, params Vector [TestCase(0, 250)] [TestCase(0, 200)] - [TestCase(1, 120)] - [TestCase(1, 80)] - public void TestSliderReversal(int pathIndex, double length) + [TestCase(1, 120, false, false)] + [TestCase(1, 80, false, false)] + [TestCase(2, 250)] + [TestCase(2, 190)] + [TestCase(3, 250)] + [TestCase(3, 190)] + public void TestSliderReversal(int pathIndex, double length, bool assertEqualDistances = true, bool assertSliderReduction = true) { var controlPoints = paths[pathIndex]; @@ -90,6 +107,215 @@ public void TestSliderReversal(int pathIndex, double length) InputManager.ReleaseKey(Key.LControl); }); + if (pathIndex == 2) + { + AddRepeatStep("Reverse slider again", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }, 2); + } + + if (assertEqualDistances) + { + AddAssert("Middle control point has the same distance from start to end", () => + { + var pathControlPoints = selectedSlider.Path.ControlPoints; + float middleToStart = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[0].Position); + float middleToEnd = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[^1].Position); + + return Precision.AlmostEquals(middleToStart, middleToEnd, 1f); + }); + } + + AddAssert("Middle control point is not at start or end", () => + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, oldStartPos) > 1 && + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, oldEndPos) > 1 + ); + + AddAssert("Slider has correct length", () => + Precision.AlmostEquals(selectedSlider.Path.Distance, oldDistance)); + + AddAssert("Slider has correct start position", () => + Vector2.Distance(selectedSlider.Position, oldEndPos) < 1); + + AddAssert("Slider has correct end position", () => + Vector2.Distance(selectedSlider.EndPosition, oldStartPos) < 1); + + AddAssert("Control points have correct types", () => + { + var newControlPointTypes = selectedSlider.Path.ControlPoints.Select(p => p.Type).ToArray(); + + return oldControlPointTypes.Take(newControlPointTypes.Length).SequenceEqual(newControlPointTypes); + }); + + if (assertSliderReduction) + { + AddStep("Move to marker", () => + { + var marker = this.ChildrenOfType().Single(); + var markerPos = (marker.ScreenSpaceDrawQuad.TopRight + marker.ScreenSpaceDrawQuad.BottomRight) / 2; + // sometimes the cursor may miss the marker's hitbox so we + // add a little offset here to be sure it lands in a clickable position. + var position = new Vector2(markerPos.X + 2f, markerPos.Y); + InputManager.MoveMouseTo(position); + }); + AddStep("Click", () => InputManager.PressButton(MouseButton.Left)); + AddStep("Reduce slider", () => + { + var middleControlPoint = this.ChildrenOfType>().ToArray()[^2]; + InputManager.MoveMouseTo(middleControlPoint); + }); + AddStep("Release click", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddStep("Save half slider info", () => + { + oldStartPos = selectedSlider.Position; + oldEndPos = selectedSlider.EndPosition; + oldDistance = selectedSlider.Path.Distance; + }); + + AddStep("Reverse slider", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }); + + AddAssert("Middle control point has the same distance from start to end", () => + { + var pathControlPoints = selectedSlider.Path.ControlPoints; + float middleToStart = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[0].Position); + float middleToEnd = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[^1].Position); + + return Precision.AlmostEquals(middleToStart, middleToEnd, 1f); + }); + + AddAssert("Middle control point is not at start or end", () => + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, oldStartPos) > 1 && + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, oldEndPos) > 1 + ); + + AddAssert("Slider has correct length", () => + Precision.AlmostEquals(selectedSlider.Path.Distance, oldDistance)); + + AddAssert("Slider has correct start position", () => + Vector2.Distance(selectedSlider.Position, oldEndPos) < 1); + + AddAssert("Slider has correct end position", () => + Vector2.Distance(selectedSlider.EndPosition, oldStartPos) < 1); + + AddAssert("Control points have correct types", () => + { + var newControlPointTypes = selectedSlider.Path.ControlPoints.Select(p => p.Type).ToArray(); + + return oldControlPointTypes.Take(newControlPointTypes.Length).SequenceEqual(newControlPointTypes); + }); + } + } + + [Test] + public void TestSegmentedSliderReversal() + { + PathControlPoint[] segmentedSliderPath = + [ + new PathControlPoint + { + Position = new Vector2(0, 0), + Type = PathType.PERFECT_CURVE + }, + new PathControlPoint + { + Position = new Vector2(100, 150), + }, + new PathControlPoint + { + Position = new Vector2(75, -50), + Type = PathType.PERFECT_CURVE + }, + new PathControlPoint + { + Position = new Vector2(225, -75), + }, + new PathControlPoint + { + Position = new Vector2(350, 50), + Type = PathType.PERFECT_CURVE + }, + new PathControlPoint + { + Position = new Vector2(500, -75), + }, + new PathControlPoint + { + Position = new Vector2(350, -120), + }, + ]; + + Vector2 oldStartPos = default; + Vector2 oldEndPos = default; + double oldDistance = default; + + var oldControlPointTypes = segmentedSliderPath.Select(p => p.Type); + + AddStep("Add slider", () => + { + var slider = new Slider + { + Position = new Vector2(0, 200), + Path = new SliderPath(segmentedSliderPath) + { + ExpectedDistance = { Value = 1314 } + } + }; + + EditorBeatmap.Add(slider); + + oldStartPos = slider.Position; + oldEndPos = slider.EndPosition; + oldDistance = slider.Path.Distance; + }); + + AddStep("Select slider", () => + { + var slider = (Slider)EditorBeatmap.HitObjects[0]; + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + + AddRepeatStep("Reverse slider", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }, 3); + + AddAssert("First arc's control is not at the slider's middle", () => + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, selectedSlider.Path.PositionAt(0.5)) > 1 + ); + + AddAssert("Last arc's control is not at the slider's middle", () => + Vector2.Distance(selectedSlider.Path.ControlPoints[1].Position, selectedSlider.Path.PositionAt(0.5)) > 1 + ); + + AddAssert("First arc centered middle control point", () => + { + var pathControlPoints = selectedSlider.Path.ControlPoints; + float middleToStart = Vector2.Distance(pathControlPoints[1].Position, pathControlPoints[0].Position); + float middleToEnd = Vector2.Distance(pathControlPoints[1].Position, pathControlPoints[2].Position); + + return Precision.AlmostEquals(middleToStart, middleToEnd, 1f); + }); + + AddAssert("Last arc centered middle control point", () => + { + var pathControlPoints = selectedSlider.Path.ControlPoints; + float middleToStart = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[^3].Position); + float middleToEnd = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[^1].Position); + + return Precision.AlmostEquals(middleToStart, middleToEnd, 1f); + }); + AddAssert("Slider has correct length", () => Precision.AlmostEquals(selectedSlider.Path.Distance, oldDistance)); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs index 52a170b84e99..4a1935061946 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs @@ -6,6 +6,7 @@ using System; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Graphics.Primitives; using osu.Framework.Testing; using osu.Framework.Utils; @@ -102,7 +103,7 @@ public void TestScalingSliderFlat() for (int i = 0; i < 100; i++) { - Assert.True(Precision.AlmostEquals(sliderPathPerfect.PositionAt(i / 100.0f), sliderPathBezier.PositionAt(i / 100.0f))); + ClassicAssert.True(Precision.AlmostEquals(sliderPathPerfect.PositionAt(i / 100.0f), sliderPathBezier.PositionAt(i / 100.0f))); } } @@ -174,7 +175,7 @@ private void assertMatchesPerfectCircle(SliderPath path) double theta = circularArcProperties.ThetaStart + (circularArcProperties.Direction * progress * circularArcProperties.ThetaRange); Vector2 vector = new Vector2((float)Math.Cos(theta), (float)Math.Sin(theta)) * circularArcProperties.Radius; - Assert.True(Precision.AlmostEquals(circularArcProperties.Centre + vector, path.PositionAt(progress), 0.01f), + ClassicAssert.True(Precision.AlmostEquals(circularArcProperties.Centre + vector, path.PositionAt(progress), 0.01f), "A perfect circle with points " + string.Join(", ", path.ControlPoints.Select(x => x.Position)) + " and radius" + circularArcProperties.Radius + "from SliderPath does not almost equal a theoretical perfect circle with " + subpoints + " subpoints" + ": " + (circularArcProperties.Centre + vector) + " - " + path.PositionAt(progress) + " = " + (circularArcProperties.Centre + vector - path.PositionAt(progress)) diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModEasy.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModEasy.cs new file mode 100644 index 000000000000..1b5d9da02bae --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModEasy.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModEasy : OsuModTestScene + { + protected override bool AllowFail => true; + + [Test] + public void TestMultipleApplication() + { + bool reapplied = false; + CreateModTest(new ModTestData + { + Mods = [new OsuModEasy { Retries = { Value = 1 } }], + Autoplay = false, + CreateBeatmap = () => + { + // do stuff to speed up fails + var b = new TestBeatmap(new OsuRuleset().RulesetInfo) + { + Difficulty = { DrainRate = 10 } + }; + + foreach (var ho in b.HitObjects) + ho.StartTime /= 4; + + return b; + }, + PassCondition = () => + { + if (((ModEasyTestPlayer)Player).FailuresSuppressed > 0 && !reapplied) + { + try + { + foreach (var mod in Player.GameplayState.Mods.OfType()) + mod.ApplyToDifficulty(new BeatmapDifficulty()); + + foreach (var mod in Player.GameplayState.Mods.OfType()) + mod.ApplyToPlayer(Player); + } + catch + { + // don't care if this fails. in fact a failure here is probably better than the alternative. + } + finally + { + reapplied = true; + } + } + + return Player.GameplayState.HasFailed && ((ModEasyTestPlayer)Player).FailuresSuppressed <= 1; + } + }); + } + + protected override TestPlayer CreateModPlayer(Ruleset ruleset) => new ModEasyTestPlayer(CurrentTestData, AllowFail); + + private partial class ModEasyTestPlayer : ModTestPlayer + { + public int FailuresSuppressed { get; private set; } + + public ModEasyTestPlayer(ModTestData data, bool allowFail) + : base(data, allowFail) + { + } + + protected override bool CheckModsAllowFailure() + { + bool failureAllowed = GameplayState.Mods.OfType().All(m => m.PerformFail()); + + if (!failureAllowed) + FailuresSuppressed++; + + return failureAllowed; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs index 57d2b9418809..31498295da80 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs @@ -2,7 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; namespace osu.Game.Rulesets.Osu.Tests.Mods { @@ -18,5 +21,39 @@ public void TestFreezeFrame() Autoplay = false, }); } + + [Test] + public void TestSkipToFirstCircleNotSuppressed() + { + CreateModTest(new ModTestData + { + Mod = new OsuModFreezeFrame(), + CreateBeatmap = () => new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 5000, Position = OsuPlayfield.BASE_SIZE / 2 } + } + }, + PassCondition = () => Player.GameplayClockContainer.GameplayStartTime > 0 + }); + } + + [Test] + public void TestSkipToFirstSpinnerNotSuppressed() + { + CreateModTest(new ModTestData + { + Mod = new OsuModFreezeFrame(), + CreateBeatmap = () => new OsuBeatmap + { + HitObjects = + { + new Spinner { StartTime = 5000, Position = OsuPlayfield.BASE_SIZE / 2 } + } + }, + PassCondition = () => Player.GameplayClockContainer.GameplayStartTime > 0 + }); + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs index 838bd35dd446..b91dcaf7576f 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs @@ -27,6 +27,9 @@ public class OsuBeatmapConversionTest : BeatmapConversionTest [TestCase("multi-segment-slider")] [TestCase("nan-slider")] [TestCase("1124896")] + [TestCase("1341554")] + [TestCase("2593923")] + [TestCase("801165")] public void Test(string name) => base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index e7a6d8ecffc2..94768c15a515 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -34,6 +34,30 @@ public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxComb public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); + [TestCase(239, "diffcalc-test")] + [TestCase(54, "zero-length-sliders")] + [TestCase(4, "very-fast-slider")] + public void TestOffsetChanges(int expectedMaxCombo, string name) + { + const double offset_iterations = 400; + var beatmap = GetBeatmap(name); + + var attributes = CreateDifficultyCalculator(beatmap).Calculate(); + double expectedStarRating = attributes.StarRating; + + for (int i = 0; i < offset_iterations; i++) + { + foreach (var beatmapHitObject in beatmap.Beatmap.HitObjects) + beatmapHitObject.StartTime++; + + attributes = CreateDifficultyCalculator(beatmap).Calculate(); + + // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences. + Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001)); + Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo)); + } + } + protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap); protected override Ruleset CreateRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/1341554-expected-conversion.json b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/1341554-expected-conversion.json new file mode 100644 index 000000000000..687676ff091c --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/1341554-expected-conversion.json @@ -0,0 +1 @@ +{"Mappings":[{"StartTime":763.0,"Objects":[{"StartTime":763.0,"EndTime":763.0,"X":309.0,"Y":230.0}]},{"StartTime":985.0,"Objects":[{"StartTime":985.0,"EndTime":985.0,"X":485.0,"Y":146.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1060.0,"EndTime":1060.0,"X":419.765442,"Y":163.340836,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":1207.0,"Objects":[{"StartTime":1207.0,"EndTime":1207.0,"X":374.0,"Y":249.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1282.0,"EndTime":1282.0,"X":309.2291,"Y":230.000534,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":1429.0,"Objects":[{"StartTime":1429.0,"EndTime":1429.0,"X":196.0,"Y":91.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1484.0,"EndTime":1484.0,"X":192.451233,"Y":57.64155,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1540.0,"EndTime":1540.0,"X":195.978485,"Y":90.79783,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1559.0,"EndTime":1559.0,"X":192.429718,"Y":57.439373,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":1651.0,"Objects":[{"StartTime":1651.0,"EndTime":1651.0,"X":124.0,"Y":173.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1725.0,"EndTime":1725.0,"X":130.363968,"Y":217.547729,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1763.0,"EndTime":1763.0,"X":124.0,"Y":173.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":1874.0,"Objects":[{"StartTime":1874.0,"EndTime":1874.0,"X":221.0,"Y":284.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1949.0,"EndTime":1949.0,"X":213.933777,"Y":216.87088,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":2096.0,"Objects":[{"StartTime":2096.0,"EndTime":2096.0,"X":292.0,"Y":86.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":2393.0,"EndTime":2393.0,"X":309.657043,"Y":231.180191,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":2540.0,"Objects":[{"StartTime":2540.0,"EndTime":2540.0,"X":314.0,"Y":328.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":2726.0,"EndTime":2726.0,"X":309.559021,"Y":230.758438,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":2874.0,"Objects":[{"StartTime":2874.0,"EndTime":2874.0,"X":421.0,"Y":300.0}]},{"StartTime":2985.0,"Objects":[{"StartTime":2985.0,"EndTime":2985.0,"X":421.0,"Y":300.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":3060.0,"EndTime":3060.0,"X":484.22522,"Y":265.267273,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":3207.0,"Objects":[{"StartTime":3207.0,"EndTime":3207.0,"X":309.0,"Y":231.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":3282.0,"EndTime":3282.0,"X":302.5318,"Y":158.8925,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":3429.0,"Objects":[{"StartTime":3429.0,"EndTime":3429.0,"X":394.0,"Y":22.0}]},{"StartTime":3540.0,"Objects":[{"StartTime":3540.0,"EndTime":3540.0,"X":461.0,"Y":72.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":3615.0,"EndTime":3615.0,"X":463.561279,"Y":140.796448,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":3762.0,"Objects":[{"StartTime":3762.0,"EndTime":3762.0,"X":378.0,"Y":183.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":3948.0,"EndTime":3948.0,"X":229.684937,"Y":160.580276,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":4096.0,"Objects":[{"StartTime":4096.0,"EndTime":4096.0,"X":229.0,"Y":161.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":4171.0,"EndTime":4171.0,"X":217.931763,"Y":234.559952,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":4318.0,"Objects":[{"StartTime":4318.0,"EndTime":4318.0,"X":61.0,"Y":384.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":4393.0,"EndTime":4393.0,"X":120.672028,"Y":339.566,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":4540.0,"Objects":[{"StartTime":4540.0,"EndTime":4540.0,"X":317.0,"Y":310.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":4615.0,"EndTime":4615.0,"X":243.821945,"Y":296.973145,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":4762.0,"Objects":[{"StartTime":4762.0,"EndTime":4762.0,"X":141.0,"Y":110.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":4948.0,"EndTime":4948.0,"X":155.885971,"Y":196.653961,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":5096.0,"Objects":[{"StartTime":5096.0,"EndTime":5096.0,"X":155.0,"Y":196.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":5282.0,"EndTime":5282.0,"X":76.3823242,"Y":283.3981,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":5429.0,"Objects":[{"StartTime":5429.0,"EndTime":5429.0,"X":212.0,"Y":366.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":5504.0,"EndTime":5504.0,"X":197.554764,"Y":312.149841,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":5651.0,"Objects":[{"StartTime":5651.0,"EndTime":5651.0,"X":206.0,"Y":286.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":5726.0,"EndTime":5726.0,"X":259.9921,"Y":299.971252,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":5874.0,"Objects":[{"StartTime":5874.0,"EndTime":5874.0,"X":281.0,"Y":321.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":5949.0,"EndTime":5949.0,"X":241.450119,"Y":360.213837,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":6096.0,"Objects":[{"StartTime":6096.0,"EndTime":6096.0,"X":124.0,"Y":246.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":6282.0,"EndTime":6282.0,"X":253.519653,"Y":211.447388,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":6429.0,"Objects":[{"StartTime":6429.0,"EndTime":6429.0,"X":253.0,"Y":211.0}]},{"StartTime":6540.0,"Objects":[{"StartTime":6540.0,"EndTime":6540.0,"X":276.0,"Y":99.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":6726.0,"EndTime":6726.0,"X":368.0168,"Y":208.521942,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":6874.0,"Objects":[{"StartTime":6874.0,"EndTime":6874.0,"X":368.0,"Y":208.0}]},{"StartTime":6985.0,"Objects":[{"StartTime":6985.0,"EndTime":6985.0,"X":430.0,"Y":96.0}]},{"StartTime":7096.0,"Objects":[{"StartTime":7096.0,"EndTime":7096.0,"X":497.0,"Y":147.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7171.0,"EndTime":7171.0,"X":501.344818,"Y":219.752075,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7318.0,"Objects":[{"StartTime":7318.0,"EndTime":7318.0,"X":414.0,"Y":379.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7504.0,"EndTime":7504.0,"X":421.0,"Y":298.0719,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7651.0,"Objects":[{"StartTime":7651.0,"EndTime":7651.0,"X":421.0,"Y":298.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7726.0,"EndTime":7726.0,"X":349.034271,"Y":308.736725,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7874.0,"Objects":[{"StartTime":7874.0,"EndTime":7874.0,"X":270.0,"Y":170.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7949.0,"EndTime":7949.0,"X":269.442352,"Y":242.5049,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":8096.0,"Objects":[{"StartTime":8096.0,"EndTime":8096.0,"X":94.0,"Y":300.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":8171.0,"EndTime":8171.0,"X":152.3368,"Y":258.00412,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":8318.0,"Objects":[{"StartTime":8318.0,"EndTime":8318.0,"X":261.0,"Y":374.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":8393.0,"EndTime":8393.0,"X":186.416916,"Y":366.102966,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":8540.0,"Objects":[{"StartTime":8540.0,"EndTime":8540.0,"X":38.0,"Y":377.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":8837.0,"EndTime":8837.0,"X":53.8668938,"Y":208.9976,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":8985.0,"Objects":[{"StartTime":8985.0,"EndTime":8985.0,"X":123.0,"Y":25.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":9060.0,"EndTime":9060.0,"X":130.897034,"Y":99.5830841,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":9207.0,"Objects":[{"StartTime":9207.0,"EndTime":9207.0,"X":217.0,"Y":242.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":9282.0,"EndTime":9282.0,"X":236.568176,"Y":169.597748,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":9429.0,"Objects":[{"StartTime":9429.0,"EndTime":9429.0,"X":48.0,"Y":92.0}]},{"StartTime":9540.0,"Objects":[{"StartTime":9540.0,"EndTime":9540.0,"X":63.0,"Y":176.0}]},{"StartTime":9651.0,"Objects":[{"StartTime":9651.0,"EndTime":9651.0,"X":83.0,"Y":259.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":9837.0,"EndTime":9837.0,"X":217.469971,"Y":242.57399,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":9985.0,"Objects":[{"StartTime":9985.0,"EndTime":9985.0,"X":274.0,"Y":312.0}]},{"StartTime":10096.0,"Objects":[{"StartTime":10096.0,"EndTime":10096.0,"X":274.0,"Y":312.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":10171.0,"EndTime":10171.0,"X":346.760681,"Y":293.8098,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":10318.0,"Objects":[{"StartTime":10318.0,"EndTime":10318.0,"X":459.0,"Y":225.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":10393.0,"EndTime":10393.0,"X":386.239319,"Y":206.80983,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":10540.0,"Objects":[{"StartTime":10540.0,"EndTime":10540.0,"X":269.0,"Y":107.0}]},{"StartTime":10651.0,"Objects":[{"StartTime":10651.0,"EndTime":10651.0,"X":276.0,"Y":54.0}]},{"StartTime":10762.0,"Objects":[{"StartTime":10762.0,"EndTime":10762.0,"X":313.0,"Y":17.0}]},{"StartTime":10874.0,"Objects":[{"StartTime":10874.0,"EndTime":10874.0,"X":363.0,"Y":9.0}]},{"StartTime":11096.0,"Objects":[{"StartTime":11096.0,"EndTime":11096.0,"X":363.0,"Y":9.0}]},{"StartTime":11207.0,"Objects":[{"StartTime":11207.0,"EndTime":11207.0,"X":432.0,"Y":68.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11282.0,"EndTime":11282.0,"X":435.697968,"Y":139.207626,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":11429.0,"Objects":[{"StartTime":11429.0,"EndTime":11429.0,"X":309.0,"Y":252.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11504.0,"EndTime":11504.0,"X":302.164825,"Y":181.178833,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":11651.0,"Objects":[{"StartTime":11651.0,"EndTime":11651.0,"X":450.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11726.0,"EndTime":11726.0,"X":375.075623,"Y":312.6326,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":11874.0,"Objects":[{"StartTime":11874.0,"EndTime":11874.0,"X":160.0,"Y":341.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":12060.0,"EndTime":12060.0,"X":186.339523,"Y":248.443192,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":12207.0,"Objects":[{"StartTime":12207.0,"EndTime":12207.0,"X":116.0,"Y":200.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":12504.0,"EndTime":12504.0,"X":104.929565,"Y":274.1413,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":12762.0,"Objects":[{"StartTime":12762.0,"EndTime":12762.0,"X":297.0,"Y":91.0}]},{"StartTime":12874.0,"Objects":[{"StartTime":12874.0,"EndTime":12874.0,"X":276.0,"Y":44.0}]},{"StartTime":12985.0,"Objects":[{"StartTime":12985.0,"EndTime":12985.0,"X":226.0,"Y":27.0}]},{"StartTime":13096.0,"Objects":[{"StartTime":13096.0,"EndTime":13096.0,"X":187.0,"Y":63.0}]},{"StartTime":13207.0,"Objects":[{"StartTime":13207.0,"EndTime":13207.0,"X":196.0,"Y":115.0}]},{"StartTime":13429.0,"Objects":[{"StartTime":13429.0,"EndTime":13429.0,"X":376.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13503.0,"EndTime":13503.0,"X":377.443817,"Y":127.395988,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13541.0,"EndTime":13541.0,"X":376.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":13651.0,"Objects":[{"StartTime":13651.0,"EndTime":13651.0,"X":436.0,"Y":220.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13837.0,"EndTime":13837.0,"X":288.903961,"Y":191.322586,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":13985.0,"Objects":[{"StartTime":13985.0,"EndTime":13985.0,"X":276.0,"Y":44.0}]},{"StartTime":14096.0,"Objects":[{"StartTime":14096.0,"EndTime":14096.0,"X":196.0,"Y":115.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":14151.0,"EndTime":14151.0,"X":159.292587,"Y":120.795906,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":14207.0,"EndTime":14207.0,"X":196.0,"Y":115.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":14262.0,"EndTime":14262.0,"X":159.292587,"Y":120.795906,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":14282.0,"EndTime":14282.0,"X":196.0,"Y":115.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":14429.0,"Objects":[{"StartTime":14429.0,"EndTime":14429.0,"X":82.0,"Y":69.0}]},{"StartTime":14540.0,"Objects":[{"StartTime":14540.0,"EndTime":14540.0,"X":106.0,"Y":190.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":14615.0,"EndTime":14615.0,"X":122.98851,"Y":263.0506,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":14762.0,"Objects":[{"StartTime":14762.0,"EndTime":14762.0,"X":218.0,"Y":383.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":14837.0,"EndTime":14837.0,"X":233.84996,"Y":309.693939,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":14985.0,"Objects":[{"StartTime":14985.0,"EndTime":14985.0,"X":26.0,"Y":231.0}]},{"StartTime":15207.0,"Objects":[{"StartTime":15207.0,"EndTime":15207.0,"X":253.0,"Y":202.0}]},{"StartTime":15318.0,"Objects":[{"StartTime":15318.0,"EndTime":15318.0,"X":331.0,"Y":271.0}]},{"StartTime":15429.0,"Objects":[{"StartTime":15429.0,"EndTime":15429.0,"X":233.0,"Y":309.0}]},{"StartTime":15651.0,"Objects":[{"StartTime":15651.0,"EndTime":15651.0,"X":389.0,"Y":73.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":15948.0,"EndTime":15948.0,"X":455.8205,"Y":109.365822,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":16096.0,"Objects":[{"StartTime":16096.0,"EndTime":16096.0,"X":391.0,"Y":165.0}]},{"StartTime":16207.0,"Objects":[{"StartTime":16207.0,"EndTime":16207.0,"X":377.0,"Y":177.0}]},{"StartTime":16318.0,"Objects":[{"StartTime":16318.0,"EndTime":16318.0,"X":365.0,"Y":187.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":16615.0,"EndTime":16615.0,"X":108.454308,"Y":184.412933,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":16762.0,"Objects":[{"StartTime":16762.0,"EndTime":16762.0,"X":73.0,"Y":319.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":17059.0,"EndTime":17059.0,"X":151.4236,"Y":251.735245,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":17207.0,"Objects":[{"StartTime":17207.0,"EndTime":17207.0,"X":139.0,"Y":258.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":17504.0,"EndTime":17504.0,"X":92.1605453,"Y":322.692932,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":17762.0,"Objects":[{"StartTime":17762.0,"EndTime":17762.0,"X":92.0,"Y":323.0}]},{"StartTime":17874.0,"Objects":[{"StartTime":17874.0,"EndTime":17874.0,"X":43.0,"Y":245.0}]},{"StartTime":17985.0,"Objects":[{"StartTime":17985.0,"EndTime":17985.0,"X":4.0,"Y":322.0}]},{"StartTime":18096.0,"Objects":[{"StartTime":18096.0,"EndTime":18096.0,"X":133.0,"Y":245.0}]},{"StartTime":18318.0,"Objects":[{"StartTime":18318.0,"EndTime":18318.0,"X":29.0,"Y":105.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":18373.0,"EndTime":18373.0,"X":36.6683846,"Y":49.6172256,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":18429.0,"EndTime":18429.0,"X":29.0464745,"Y":104.664345,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":18448.0,"EndTime":18448.0,"X":36.71486,"Y":49.28157,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":18540.0,"Objects":[{"StartTime":18540.0,"EndTime":18540.0,"X":50.0,"Y":30.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":18726.0,"EndTime":18726.0,"X":187.203888,"Y":30.4987259,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":18985.0,"Objects":[{"StartTime":18985.0,"EndTime":18985.0,"X":240.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":19171.0,"EndTime":19171.0,"X":377.9097,"Y":110.78511,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":19318.0,"Objects":[{"StartTime":19318.0,"EndTime":19318.0,"X":409.0,"Y":213.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":19504.0,"EndTime":19504.0,"X":263.653748,"Y":204.007446,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":19651.0,"Objects":[{"StartTime":19651.0,"EndTime":19651.0,"X":119.0,"Y":187.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":19726.0,"EndTime":19726.0,"X":125.040916,"Y":261.756317,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":19874.0,"Objects":[{"StartTime":19874.0,"EndTime":19874.0,"X":179.0,"Y":338.0}]},{"StartTime":19985.0,"Objects":[{"StartTime":19985.0,"EndTime":19985.0,"X":45.0,"Y":307.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":20040.0,"EndTime":20040.0,"X":8.848415,"Y":298.3925,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":20060.0,"EndTime":20060.0,"X":45.0,"Y":307.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":20207.0,"Objects":[{"StartTime":20207.0,"EndTime":20207.0,"X":103.0,"Y":380.0}]},{"StartTime":20318.0,"Objects":[{"StartTime":20318.0,"EndTime":20318.0,"X":212.0,"Y":257.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":20393.0,"EndTime":20393.0,"X":234.732208,"Y":187.976425,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":20540.0,"Objects":[{"StartTime":20540.0,"EndTime":20540.0,"X":111.0,"Y":118.0}]},{"StartTime":20762.0,"Objects":[{"StartTime":20762.0,"EndTime":20762.0,"X":111.0,"Y":118.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":20948.0,"EndTime":20948.0,"X":185.592651,"Y":110.193794,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":21096.0,"Objects":[{"StartTime":21096.0,"EndTime":21096.0,"X":256.0,"Y":18.0}]},{"StartTime":21207.0,"Objects":[{"StartTime":21207.0,"EndTime":21207.0,"X":337.0,"Y":121.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":21337.0,"EndTime":21337.0,"X":383.95816,"Y":25.23836,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":21429.0,"Objects":[{"StartTime":21429.0,"EndTime":21429.0,"X":384.0,"Y":26.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":21559.0,"EndTime":21559.0,"X":443.158325,"Y":114.866982,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":21651.0,"Objects":[{"StartTime":21651.0,"EndTime":21651.0,"X":443.0,"Y":114.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":21781.0,"EndTime":21781.0,"X":336.563934,"Y":123.06649,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":21874.0,"Objects":[{"StartTime":21874.0,"EndTime":21874.0,"X":352.0,"Y":223.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":21949.0,"EndTime":21949.0,"X":458.863068,"Y":242.7167,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":22096.0,"Objects":[{"StartTime":22096.0,"EndTime":22096.0,"X":322.0,"Y":343.0}]},{"StartTime":22207.0,"Objects":[{"StartTime":22207.0,"EndTime":22207.0,"X":259.0,"Y":270.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":22318.0,"EndTime":22318.0,"X":186.556473,"Y":265.58252,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":22393.0,"EndTime":22393.0,"X":259.0,"Y":270.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":22540.0,"Objects":[{"StartTime":22540.0,"EndTime":22540.0,"X":86.0,"Y":360.0}]},{"StartTime":22651.0,"Objects":[{"StartTime":22651.0,"EndTime":22651.0,"X":15.0,"Y":295.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":22762.0,"EndTime":22762.0,"X":3.18144321,"Y":220.937042,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":22837.0,"EndTime":22837.0,"X":15.0,"Y":295.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":22985.0,"Objects":[{"StartTime":22985.0,"EndTime":22985.0,"X":94.0,"Y":384.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":23282.0,"EndTime":23282.0,"X":112.333351,"Y":278.012543,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":23429.0,"Objects":[{"StartTime":23429.0,"EndTime":23429.0,"X":0.0,"Y":211.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":23504.0,"EndTime":23504.0,"X":73.58056,"Y":196.477524,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":23651.0,"Objects":[{"StartTime":23651.0,"EndTime":23651.0,"X":215.0,"Y":134.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":23726.0,"EndTime":23726.0,"X":142.0318,"Y":116.661018,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":23874.0,"Objects":[{"StartTime":23874.0,"EndTime":23874.0,"X":33.0,"Y":124.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":24060.0,"EndTime":24060.0,"X":42.19049,"Y":11.8760223,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":24318.0,"Objects":[{"StartTime":24318.0,"EndTime":24318.0,"X":150.0,"Y":269.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":24504.0,"EndTime":24504.0,"X":161.849289,"Y":194.941956,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":24651.0,"Objects":[{"StartTime":24651.0,"EndTime":24651.0,"X":229.0,"Y":134.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":24726.0,"EndTime":24726.0,"X":339.500732,"Y":155.114792,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":24874.0,"Objects":[{"StartTime":24874.0,"EndTime":24874.0,"X":486.0,"Y":268.0}]},{"StartTime":24985.0,"Objects":[{"StartTime":24985.0,"EndTime":24985.0,"X":410.0,"Y":119.0}]},{"StartTime":25096.0,"Objects":[{"StartTime":25096.0,"EndTime":25096.0,"X":381.0,"Y":213.0}]},{"StartTime":25207.0,"Objects":[{"StartTime":25207.0,"EndTime":25207.0,"X":512.0,"Y":120.0}]},{"StartTime":25429.0,"Objects":[{"StartTime":25429.0,"EndTime":25429.0,"X":247.0,"Y":36.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":25484.0,"EndTime":25484.0,"X":210.424835,"Y":28.8155918,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":25540.0,"EndTime":25540.0,"X":246.778336,"Y":35.95646,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":25559.0,"EndTime":25559.0,"X":210.203171,"Y":28.77205,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":25651.0,"Objects":[{"StartTime":25651.0,"EndTime":25651.0,"X":185.0,"Y":24.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":25837.0,"EndTime":25837.0,"X":171.310989,"Y":155.469345,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":26096.0,"Objects":[{"StartTime":26096.0,"EndTime":26096.0,"X":253.0,"Y":219.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":26282.0,"EndTime":26282.0,"X":248.797211,"Y":354.1396,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":26429.0,"Objects":[{"StartTime":26429.0,"EndTime":26429.0,"X":100.0,"Y":363.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":26615.0,"EndTime":26615.0,"X":249.760269,"Y":354.523,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":26762.0,"Objects":[{"StartTime":26762.0,"EndTime":26762.0,"X":404.0,"Y":262.0}]},{"StartTime":26874.0,"Objects":[{"StartTime":26874.0,"EndTime":26874.0,"X":390.0,"Y":352.0}]},{"StartTime":26985.0,"Objects":[{"StartTime":26985.0,"EndTime":26985.0,"X":314.0,"Y":295.0}]},{"StartTime":27096.0,"Objects":[{"StartTime":27096.0,"EndTime":27096.0,"X":425.0,"Y":256.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":27151.0,"EndTime":27151.0,"X":461.755035,"Y":250.514175,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":27171.0,"EndTime":27171.0,"X":425.0,"Y":256.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":27318.0,"Objects":[{"StartTime":27318.0,"EndTime":27318.0,"X":329.0,"Y":216.0}]},{"StartTime":27429.0,"Objects":[{"StartTime":27429.0,"EndTime":27429.0,"X":193.0,"Y":177.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":27504.0,"EndTime":27504.0,"X":266.260956,"Y":160.94281,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":27651.0,"Objects":[{"StartTime":27651.0,"EndTime":27651.0,"X":322.0,"Y":107.0}]},{"StartTime":27874.0,"Objects":[{"StartTime":27874.0,"EndTime":27874.0,"X":322.0,"Y":107.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":28060.0,"EndTime":28060.0,"X":311.7376,"Y":219.030945,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":28207.0,"Objects":[{"StartTime":28207.0,"EndTime":28207.0,"X":110.0,"Y":299.0}]},{"StartTime":28318.0,"Objects":[{"StartTime":28318.0,"EndTime":28318.0,"X":164.0,"Y":231.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":28448.0,"EndTime":28448.0,"X":135.6063,"Y":327.122955,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":28540.0,"Objects":[{"StartTime":28540.0,"EndTime":28540.0,"X":30.0,"Y":284.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":28670.0,"EndTime":28670.0,"X":127.158829,"Y":259.8269,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":28762.0,"Objects":[{"StartTime":28762.0,"EndTime":28762.0,"X":148.0,"Y":371.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":28892.0,"EndTime":28892.0,"X":78.25478,"Y":298.6825,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":28985.0,"Objects":[{"StartTime":28985.0,"EndTime":28985.0,"X":194.0,"Y":201.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":29060.0,"EndTime":29060.0,"X":299.8987,"Y":225.443588,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":29207.0,"Objects":[{"StartTime":29207.0,"EndTime":29207.0,"X":492.0,"Y":114.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":29282.0,"EndTime":29282.0,"X":422.503,"Y":138.437851,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":29429.0,"Objects":[{"StartTime":29429.0,"EndTime":29429.0,"X":324.0,"Y":102.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":29504.0,"EndTime":29504.0,"X":281.7771,"Y":42.667614,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":29651.0,"Objects":[{"StartTime":29651.0,"EndTime":29651.0,"X":418.0,"Y":17.0}]},{"StartTime":29874.0,"Objects":[{"StartTime":29874.0,"EndTime":29874.0,"X":495.0,"Y":201.0}]},{"StartTime":30096.0,"Objects":[{"StartTime":30096.0,"EndTime":30096.0,"X":221.0,"Y":136.0}]},{"StartTime":30207.0,"Objects":[{"StartTime":30207.0,"EndTime":30207.0,"X":299.0,"Y":188.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":30393.0,"EndTime":30393.0,"X":281.51004,"Y":328.410828,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":30540.0,"Objects":[{"StartTime":30540.0,"EndTime":30540.0,"X":115.0,"Y":334.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":30837.0,"EndTime":30837.0,"X":166.044785,"Y":247.203171,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":30985.0,"Objects":[{"StartTime":30985.0,"EndTime":30985.0,"X":216.0,"Y":326.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":31012.0,"EndTime":31012.0,"X":279.647339,"Y":329.616333,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":31429.0,"Objects":[{"StartTime":31429.0,"EndTime":31429.0,"X":280.0,"Y":330.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":31504.0,"EndTime":31504.0,"X":290.840027,"Y":255.7875,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":31651.0,"Objects":[{"StartTime":31651.0,"EndTime":31651.0,"X":426.0,"Y":252.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":31726.0,"EndTime":31726.0,"X":436.840027,"Y":177.7875,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":31874.0,"Objects":[{"StartTime":31874.0,"EndTime":31874.0,"X":253.0,"Y":158.0}]},{"StartTime":31985.0,"Objects":[{"StartTime":31985.0,"EndTime":31985.0,"X":258.0,"Y":132.0}]},{"StartTime":32096.0,"Objects":[{"StartTime":32096.0,"EndTime":32096.0,"X":337.0,"Y":111.0}]},{"StartTime":32207.0,"Objects":[{"StartTime":32207.0,"EndTime":32207.0,"X":341.0,"Y":85.0}]},{"StartTime":32318.0,"Objects":[{"StartTime":32318.0,"EndTime":32318.0,"X":271.0,"Y":30.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":32448.0,"EndTime":32448.0,"X":162.253082,"Y":25.8848,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":32540.0,"Objects":[{"StartTime":32540.0,"EndTime":32540.0,"X":163.0,"Y":26.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":32726.0,"EndTime":32726.0,"X":144.749512,"Y":174.88559,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":32985.0,"Objects":[{"StartTime":32985.0,"EndTime":32985.0,"X":445.0,"Y":343.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":33171.0,"EndTime":33171.0,"X":404.5491,"Y":255.923309,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":33429.0,"Objects":[{"StartTime":33429.0,"EndTime":33429.0,"X":240.0,"Y":257.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":33615.0,"EndTime":33615.0,"X":280.0188,"Y":182.645447,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":33874.0,"Objects":[{"StartTime":33874.0,"EndTime":33874.0,"X":68.0,"Y":333.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":34060.0,"EndTime":34060.0,"X":40.12644,"Y":256.4784,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":34318.0,"Objects":[{"StartTime":34318.0,"EndTime":34318.0,"X":344.0,"Y":347.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":34393.0,"EndTime":34393.0,"X":464.08136,"Y":329.892731,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":34540.0,"Objects":[{"StartTime":34540.0,"EndTime":34540.0,"X":452.0,"Y":255.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":34726.0,"EndTime":34726.0,"X":338.6652,"Y":265.867065,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":34874.0,"Objects":[{"StartTime":34874.0,"EndTime":34874.0,"X":256.0,"Y":220.0}]},{"StartTime":34985.0,"Objects":[{"StartTime":34985.0,"EndTime":34985.0,"X":256.0,"Y":220.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":35060.0,"EndTime":35060.0,"X":256.0,"Y":145.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":35207.0,"Objects":[{"StartTime":35207.0,"EndTime":35207.0,"X":256.0,"Y":70.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":35282.0,"EndTime":35282.0,"X":256.0,"Y":145.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":35429.0,"Objects":[{"StartTime":35429.0,"EndTime":35429.0,"X":112.0,"Y":312.0}]},{"StartTime":35540.0,"Objects":[{"StartTime":35540.0,"EndTime":35540.0,"X":60.0,"Y":255.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":35726.0,"EndTime":35726.0,"X":173.334808,"Y":265.867065,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":35874.0,"Objects":[{"StartTime":35874.0,"EndTime":35874.0,"X":169.0,"Y":350.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":36282.0,"EndTime":36282.0,"X":48.1567,"Y":333.550873,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":36429.0,"Objects":[{"StartTime":36429.0,"EndTime":36429.0,"X":62.0,"Y":169.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":36540.0,"EndTime":36540.0,"X":72.6066055,"Y":243.246216,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":36615.0,"EndTime":36615.0,"X":62.0,"Y":169.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":36762.0,"Objects":[{"StartTime":36762.0,"EndTime":36762.0,"X":134.0,"Y":61.0}]},{"StartTime":36874.0,"Objects":[{"StartTime":36874.0,"EndTime":36874.0,"X":201.0,"Y":113.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":36985.0,"EndTime":36985.0,"X":211.6066,"Y":187.246216,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":37060.0,"EndTime":37060.0,"X":201.0,"Y":113.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":37207.0,"Objects":[{"StartTime":37207.0,"EndTime":37207.0,"X":298.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":37282.0,"EndTime":37282.0,"X":312.225616,"Y":198.361481,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":37429.0,"Objects":[{"StartTime":37429.0,"EndTime":37429.0,"X":330.0,"Y":114.0}]},{"StartTime":37540.0,"Objects":[{"StartTime":37540.0,"EndTime":37540.0,"X":446.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":37726.0,"EndTime":37726.0,"X":312.0408,"Y":197.883438,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":37874.0,"Objects":[{"StartTime":37874.0,"EndTime":37874.0,"X":231.0,"Y":240.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":37949.0,"EndTime":37949.0,"X":229.158188,"Y":166.818878,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":38096.0,"Objects":[{"StartTime":38096.0,"EndTime":38096.0,"X":325.0,"Y":285.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":38171.0,"EndTime":38171.0,"X":175.573792,"Y":298.107574,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":38540.0,"Objects":[{"StartTime":38540.0,"EndTime":38540.0,"X":175.0,"Y":298.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":38615.0,"EndTime":38615.0,"X":165.884415,"Y":372.44397,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":38762.0,"Objects":[{"StartTime":38762.0,"EndTime":38762.0,"X":75.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":38837.0,"EndTime":38837.0,"X":65.8844147,"Y":282.44397,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":38985.0,"Objects":[{"StartTime":38985.0,"EndTime":38985.0,"X":233.0,"Y":74.0}]},{"StartTime":39096.0,"Objects":[{"StartTime":39096.0,"EndTime":39096.0,"X":231.0,"Y":98.0}]},{"StartTime":39207.0,"Objects":[{"StartTime":39207.0,"EndTime":39207.0,"X":156.0,"Y":139.0}]},{"StartTime":39318.0,"Objects":[{"StartTime":39318.0,"EndTime":39318.0,"X":155.0,"Y":165.0}]},{"StartTime":39429.0,"Objects":[{"StartTime":39429.0,"EndTime":39429.0,"X":227.0,"Y":215.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":39559.0,"EndTime":39559.0,"X":336.996735,"Y":222.441727,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":39651.0,"Objects":[{"StartTime":39651.0,"EndTime":39651.0,"X":336.0,"Y":222.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":39837.0,"EndTime":39837.0,"X":364.5033,"Y":74.73303,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":40096.0,"Objects":[{"StartTime":40096.0,"EndTime":40096.0,"X":81.0,"Y":35.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":40282.0,"EndTime":40282.0,"X":127.2737,"Y":104.866875,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":40540.0,"Objects":[{"StartTime":40540.0,"EndTime":40540.0,"X":272.0,"Y":158.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":40726.0,"EndTime":40726.0,"X":224.732422,"Y":227.8874,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":40985.0,"Objects":[{"StartTime":40985.0,"EndTime":40985.0,"X":423.0,"Y":36.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":41171.0,"EndTime":41171.0,"X":443.179352,"Y":116.766846,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":41429.0,"Objects":[{"StartTime":41429.0,"EndTime":41429.0,"X":512.0,"Y":278.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":41504.0,"EndTime":41504.0,"X":373.7834,"Y":280.097351,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":41651.0,"Objects":[{"StartTime":41651.0,"EndTime":41651.0,"X":359.0,"Y":302.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":41781.0,"EndTime":41781.0,"X":312.524048,"Y":206.435242,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":41874.0,"Objects":[{"StartTime":41874.0,"EndTime":41874.0,"X":322.0,"Y":190.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":42004.0,"EndTime":42004.0,"X":433.261749,"Y":173.354538,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":42096.0,"Objects":[{"StartTime":42096.0,"EndTime":42096.0,"X":443.0,"Y":159.0}]},{"StartTime":42318.0,"Objects":[{"StartTime":42318.0,"EndTime":42318.0,"X":240.0,"Y":52.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":42393.0,"EndTime":42393.0,"X":244.301,"Y":121.796005,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":42540.0,"Objects":[{"StartTime":42540.0,"EndTime":42540.0,"X":177.0,"Y":166.0}]},{"StartTime":42651.0,"Objects":[{"StartTime":42651.0,"EndTime":42651.0,"X":163.0,"Y":151.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":42837.0,"EndTime":42837.0,"X":189.527557,"Y":288.624542,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":42985.0,"Objects":[{"StartTime":42985.0,"EndTime":42985.0,"X":131.0,"Y":365.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":43171.0,"EndTime":43171.0,"X":263.826355,"Y":333.7347,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":43318.0,"Objects":[{"StartTime":43318.0,"EndTime":43318.0,"X":335.0,"Y":377.0}]},{"StartTime":43429.0,"Objects":[{"StartTime":43429.0,"EndTime":43429.0,"X":442.0,"Y":239.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":43726.0,"EndTime":43726.0,"X":444.975067,"Y":103.950119,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":43874.0,"Objects":[{"StartTime":43874.0,"EndTime":43874.0,"X":444.0,"Y":103.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":43949.0,"EndTime":43949.0,"X":371.940247,"Y":120.881523,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":44096.0,"Objects":[{"StartTime":44096.0,"EndTime":44096.0,"X":249.0,"Y":28.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":44171.0,"EndTime":44171.0,"X":320.874725,"Y":46.2887573,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":44318.0,"Objects":[{"StartTime":44318.0,"EndTime":44318.0,"X":364.0,"Y":201.0}]},{"StartTime":44429.0,"Objects":[{"StartTime":44429.0,"EndTime":44429.0,"X":332.0,"Y":195.0}]},{"StartTime":44540.0,"Objects":[{"StartTime":44540.0,"EndTime":44540.0,"X":251.0,"Y":135.0}]},{"StartTime":44651.0,"Objects":[{"StartTime":44651.0,"EndTime":44651.0,"X":281.0,"Y":123.0}]},{"StartTime":44762.0,"Objects":[{"StartTime":44762.0,"EndTime":44762.0,"X":332.0,"Y":195.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":44948.0,"EndTime":44948.0,"X":306.331055,"Y":299.344849,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":45207.0,"Objects":[{"StartTime":45207.0,"EndTime":45207.0,"X":61.0,"Y":25.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":45282.0,"EndTime":45282.0,"X":83.3818054,"Y":135.2511,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":45651.0,"Objects":[{"StartTime":45651.0,"EndTime":45651.0,"X":84.0,"Y":136.0}]},{"StartTime":46096.0,"Objects":[{"StartTime":46096.0,"EndTime":46096.0,"X":84.0,"Y":136.0}]},{"StartTime":46207.0,"Objects":[{"StartTime":46207.0,"EndTime":46207.0,"X":176.0,"Y":33.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":46282.0,"EndTime":46282.0,"X":166.495789,"Y":88.44125,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":46429.0,"Objects":[{"StartTime":46429.0,"EndTime":46429.0,"X":219.0,"Y":207.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":46504.0,"EndTime":46504.0,"X":231.938934,"Y":152.258362,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":46651.0,"Objects":[{"StartTime":46651.0,"EndTime":46651.0,"X":312.0,"Y":65.0}]},{"StartTime":46762.0,"Objects":[{"StartTime":46762.0,"EndTime":46762.0,"X":312.0,"Y":65.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":46837.0,"EndTime":46837.0,"X":365.301147,"Y":82.97364,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":46985.0,"Objects":[{"StartTime":46985.0,"EndTime":46985.0,"X":512.0,"Y":176.0}]},{"StartTime":47096.0,"Objects":[{"StartTime":47096.0,"EndTime":47096.0,"X":421.0,"Y":192.0}]},{"StartTime":47429.0,"Objects":[{"StartTime":47429.0,"EndTime":47429.0,"X":421.0,"Y":192.0}]},{"StartTime":47651.0,"Objects":[{"StartTime":47651.0,"EndTime":47651.0,"X":402.0,"Y":357.0}]},{"StartTime":47762.0,"Objects":[{"StartTime":47762.0,"EndTime":47762.0,"X":394.0,"Y":277.0}]},{"StartTime":47874.0,"Objects":[{"StartTime":47874.0,"EndTime":47874.0,"X":328.0,"Y":324.0}]},{"StartTime":48318.0,"Objects":[{"StartTime":48318.0,"EndTime":48318.0,"X":328.0,"Y":324.0}]},{"StartTime":48540.0,"Objects":[{"StartTime":48540.0,"EndTime":48540.0,"X":110.0,"Y":357.0}]},{"StartTime":48651.0,"Objects":[{"StartTime":48651.0,"EndTime":48651.0,"X":118.0,"Y":277.0}]},{"StartTime":48763.0,"Objects":[{"StartTime":48763.0,"EndTime":48763.0,"X":184.0,"Y":324.0}]},{"StartTime":48874.0,"Objects":[{"StartTime":48874.0,"EndTime":48874.0,"X":110.0,"Y":357.0}]},{"StartTime":49207.0,"Objects":[{"StartTime":49207.0,"EndTime":49207.0,"X":110.0,"Y":357.0}]},{"StartTime":49651.0,"Objects":[{"StartTime":49651.0,"EndTime":49651.0,"X":110.0,"Y":357.0}]},{"StartTime":49762.0,"Objects":[{"StartTime":49762.0,"EndTime":49762.0,"X":0.0,"Y":283.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":49837.0,"EndTime":49837.0,"X":52.00481,"Y":302.320679,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":49985.0,"Objects":[{"StartTime":49985.0,"EndTime":49985.0,"X":188.0,"Y":219.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":50060.0,"EndTime":50060.0,"X":139.90033,"Y":245.560135,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":50207.0,"Objects":[{"StartTime":50207.0,"EndTime":50207.0,"X":49.0,"Y":137.0}]},{"StartTime":50318.0,"Objects":[{"StartTime":50318.0,"EndTime":50318.0,"X":49.0,"Y":137.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":50393.0,"EndTime":50393.0,"X":68.7774048,"Y":188.112656,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":50540.0,"Objects":[{"StartTime":50540.0,"EndTime":50540.0,"X":107.0,"Y":67.0}]},{"StartTime":50651.0,"Objects":[{"StartTime":50651.0,"EndTime":50651.0,"X":32.0,"Y":15.0}]},{"StartTime":50985.0,"Objects":[{"StartTime":50985.0,"EndTime":50985.0,"X":32.0,"Y":15.0}]},{"StartTime":51207.0,"Objects":[{"StartTime":51207.0,"EndTime":51207.0,"X":265.0,"Y":114.0}]},{"StartTime":51318.0,"Objects":[{"StartTime":51318.0,"EndTime":51318.0,"X":254.0,"Y":196.0}]},{"StartTime":51429.0,"Objects":[{"StartTime":51429.0,"EndTime":51429.0,"X":241.0,"Y":279.0}]},{"StartTime":51651.0,"Objects":[{"StartTime":51651.0,"EndTime":51651.0,"X":241.0,"Y":279.0}]},{"StartTime":51762.0,"Objects":[{"StartTime":51762.0,"EndTime":51762.0,"X":336.0,"Y":207.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":52059.0,"EndTime":52059.0,"X":391.603638,"Y":270.527466,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":52318.0,"Objects":[{"StartTime":52318.0,"EndTime":52318.0,"X":83.0,"Y":206.0}]},{"StartTime":52429.0,"Objects":[{"StartTime":52429.0,"EndTime":52429.0,"X":83.0,"Y":206.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":52504.0,"EndTime":52504.0,"X":100.787811,"Y":259.363434,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":52651.0,"Objects":[{"StartTime":52651.0,"EndTime":52651.0,"X":40.0,"Y":383.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":52726.0,"EndTime":52726.0,"X":78.99291,"Y":342.874847,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":52874.0,"Objects":[{"StartTime":52874.0,"EndTime":52874.0,"X":214.0,"Y":334.0}]},{"StartTime":52985.0,"Objects":[{"StartTime":52985.0,"EndTime":52985.0,"X":214.0,"Y":334.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":53060.0,"EndTime":53060.0,"X":160.677017,"Y":317.0179,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":53207.0,"Objects":[{"StartTime":53207.0,"EndTime":53207.0,"X":151.0,"Y":160.0}]},{"StartTime":53318.0,"Objects":[{"StartTime":53318.0,"EndTime":53318.0,"X":188.0,"Y":135.0}]},{"StartTime":53429.0,"Objects":[{"StartTime":53429.0,"EndTime":53429.0,"X":232.0,"Y":129.0}]},{"StartTime":53540.0,"Objects":[{"StartTime":53540.0,"EndTime":53540.0,"X":273.0,"Y":146.0}]},{"StartTime":53651.0,"Objects":[{"StartTime":53651.0,"EndTime":53651.0,"X":339.0,"Y":198.0}]},{"StartTime":53762.0,"Objects":[{"StartTime":53762.0,"EndTime":53762.0,"X":383.0,"Y":199.0}]},{"StartTime":53874.0,"Objects":[{"StartTime":53874.0,"EndTime":53874.0,"X":426.0,"Y":185.0}]},{"StartTime":53985.0,"Objects":[{"StartTime":53985.0,"EndTime":53985.0,"X":450.0,"Y":147.0}]},{"StartTime":54096.0,"Objects":[{"StartTime":54096.0,"EndTime":54096.0,"X":444.0,"Y":61.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":54171.0,"EndTime":54171.0,"X":404.198761,"Y":22.7040062,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":54318.0,"Objects":[{"StartTime":54318.0,"EndTime":54318.0,"X":301.0,"Y":28.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":54393.0,"EndTime":54393.0,"X":259.281525,"Y":62.49509,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":54540.0,"Objects":[{"StartTime":54540.0,"EndTime":54540.0,"X":189.0,"Y":271.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":54615.0,"EndTime":54615.0,"X":208.7961,"Y":220.140625,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":54762.0,"Objects":[{"StartTime":54762.0,"EndTime":54762.0,"X":186.0,"Y":114.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":54837.0,"EndTime":54837.0,"X":149.98996,"Y":73.19951,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":54985.0,"Objects":[{"StartTime":54985.0,"EndTime":54985.0,"X":27.0,"Y":137.0}]},{"StartTime":55096.0,"Objects":[{"StartTime":55096.0,"EndTime":55096.0,"X":34.0,"Y":167.0}]},{"StartTime":55207.0,"Objects":[{"StartTime":55207.0,"EndTime":55207.0,"X":122.0,"Y":204.0}]},{"StartTime":55318.0,"Objects":[{"StartTime":55318.0,"EndTime":55318.0,"X":116.0,"Y":178.0}]},{"StartTime":55429.0,"Objects":[{"StartTime":55429.0,"EndTime":55429.0,"X":48.0,"Y":249.0}]},{"StartTime":55540.0,"Objects":[{"StartTime":55540.0,"EndTime":55540.0,"X":54.0,"Y":274.0}]},{"StartTime":55651.0,"Objects":[{"StartTime":55651.0,"EndTime":55651.0,"X":124.0,"Y":329.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":55726.0,"EndTime":55726.0,"X":179.117538,"Y":319.596771,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":55874.0,"Objects":[{"StartTime":55874.0,"EndTime":55874.0,"X":320.0,"Y":185.0}]},{"StartTime":55985.0,"Objects":[{"StartTime":55985.0,"EndTime":55985.0,"X":287.0,"Y":175.0}]},{"StartTime":56096.0,"Objects":[{"StartTime":56096.0,"EndTime":56096.0,"X":254.0,"Y":181.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":56171.0,"EndTime":56171.0,"X":262.399933,"Y":236.321579,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":56318.0,"Objects":[{"StartTime":56318.0,"EndTime":56318.0,"X":337.0,"Y":347.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":56393.0,"EndTime":56393.0,"X":350.0131,"Y":293.286041,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":56540.0,"Objects":[{"StartTime":56540.0,"EndTime":56540.0,"X":418.0,"Y":197.0}]},{"StartTime":56651.0,"Objects":[{"StartTime":56651.0,"EndTime":56651.0,"X":418.0,"Y":197.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":56726.0,"EndTime":56726.0,"X":472.82196,"Y":184.405762,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":56874.0,"Objects":[{"StartTime":56874.0,"EndTime":56874.0,"X":329.0,"Y":114.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":56949.0,"EndTime":56949.0,"X":275.1002,"Y":97.91051,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":57096.0,"Objects":[{"StartTime":57096.0,"EndTime":57096.0,"X":436.0,"Y":59.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":57171.0,"EndTime":57171.0,"X":417.73645,"Y":112.2025,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":57318.0,"Objects":[{"StartTime":57318.0,"EndTime":57318.0,"X":332.0,"Y":194.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":57429.0,"EndTime":57429.0,"X":349.292969,"Y":247.525848,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":57504.0,"EndTime":57504.0,"X":332.0,"Y":194.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":57651.0,"Objects":[{"StartTime":57651.0,"EndTime":57651.0,"X":202.0,"Y":194.0}]},{"StartTime":57762.0,"Objects":[{"StartTime":57762.0,"EndTime":57762.0,"X":224.0,"Y":233.0}]},{"StartTime":57874.0,"Objects":[{"StartTime":57874.0,"EndTime":57874.0,"X":222.0,"Y":279.0}]},{"StartTime":57985.0,"Objects":[{"StartTime":57985.0,"EndTime":57985.0,"X":193.0,"Y":314.0}]},{"StartTime":58096.0,"Objects":[{"StartTime":58096.0,"EndTime":58096.0,"X":144.0,"Y":244.0}]},{"StartTime":58207.0,"Objects":[{"StartTime":58207.0,"EndTime":58207.0,"X":127.0,"Y":214.0}]},{"StartTime":58318.0,"Objects":[{"StartTime":58318.0,"EndTime":58318.0,"X":126.0,"Y":180.0}]},{"StartTime":58429.0,"Objects":[{"StartTime":58429.0,"EndTime":58429.0,"X":139.0,"Y":149.0}]},{"StartTime":58540.0,"Objects":[{"StartTime":58540.0,"EndTime":58540.0,"X":224.0,"Y":113.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58726.0,"EndTime":58726.0,"X":198.29718,"Y":184.3571,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":58874.0,"Objects":[{"StartTime":58874.0,"EndTime":58874.0,"X":299.0,"Y":319.0}]},{"StartTime":58985.0,"Objects":[{"StartTime":58985.0,"EndTime":58985.0,"X":299.0,"Y":319.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59282.0,"EndTime":59282.0,"X":356.653656,"Y":227.930466,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59429.0,"Objects":[{"StartTime":59429.0,"EndTime":59429.0,"X":428.0,"Y":181.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59615.0,"EndTime":59615.0,"X":418.7787,"Y":18.6044426,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59874.0,"Objects":[{"StartTime":59874.0,"EndTime":59874.0,"X":418.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59911.0,"EndTime":59911.0,"X":393.055359,"Y":16.3370247,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59948.0,"EndTime":59948.0,"X":418.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59985.0,"EndTime":59985.0,"X":393.055359,"Y":16.3370247,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60022.0,"EndTime":60022.0,"X":418.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60059.0,"EndTime":60059.0,"X":393.055359,"Y":16.3370247,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60060.0,"EndTime":60060.0,"X":418.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":60207.0,"Objects":[{"StartTime":60207.0,"EndTime":60207.0,"X":428.0,"Y":181.0}]},{"StartTime":60318.0,"Objects":[{"StartTime":60318.0,"EndTime":60318.0,"X":352.0,"Y":209.0}]},{"StartTime":60429.0,"Objects":[{"StartTime":60429.0,"EndTime":60429.0,"X":278.0,"Y":177.0}]},{"StartTime":60540.0,"Objects":[{"StartTime":60540.0,"EndTime":60540.0,"X":208.0,"Y":225.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60595.0,"EndTime":60595.0,"X":219.751709,"Y":260.255127,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60615.0,"EndTime":60615.0,"X":208.0,"Y":225.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":60762.0,"Objects":[{"StartTime":60762.0,"EndTime":60762.0,"X":71.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60799.0,"EndTime":60799.0,"X":66.7759,"Y":119.359444,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60836.0,"EndTime":60836.0,"X":71.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60837.0,"EndTime":60837.0,"X":66.7759,"Y":119.359444,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":60985.0,"Objects":[{"StartTime":60985.0,"EndTime":60985.0,"X":145.0,"Y":86.0}]},{"StartTime":61096.0,"Objects":[{"StartTime":61096.0,"EndTime":61096.0,"X":163.0,"Y":127.0}]},{"StartTime":61207.0,"Objects":[{"StartTime":61207.0,"EndTime":61207.0,"X":161.0,"Y":171.0}]},{"StartTime":61318.0,"Objects":[{"StartTime":61318.0,"EndTime":61318.0,"X":136.0,"Y":208.0}]},{"StartTime":61429.0,"Objects":[{"StartTime":61429.0,"EndTime":61429.0,"X":99.0,"Y":231.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":61504.0,"EndTime":61504.0,"X":106.705917,"Y":300.142578,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":61651.0,"Objects":[{"StartTime":61651.0,"EndTime":61651.0,"X":177.0,"Y":378.0}]},{"StartTime":61762.0,"Objects":[{"StartTime":61762.0,"EndTime":61762.0,"X":177.0,"Y":378.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":61948.0,"EndTime":61948.0,"X":336.375977,"Y":319.826965,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":62096.0,"Objects":[{"StartTime":62096.0,"EndTime":62096.0,"X":417.0,"Y":293.0}]},{"StartTime":62207.0,"Objects":[{"StartTime":62207.0,"EndTime":62207.0,"X":438.0,"Y":263.0}]},{"StartTime":62318.0,"Objects":[{"StartTime":62318.0,"EndTime":62318.0,"X":436.0,"Y":225.0}]},{"StartTime":62429.0,"Objects":[{"StartTime":62429.0,"EndTime":62429.0,"X":412.0,"Y":196.0}]},{"StartTime":62540.0,"Objects":[{"StartTime":62540.0,"EndTime":62540.0,"X":320.0,"Y":172.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":62567.0,"EndTime":62567.0,"X":296.5699,"Y":200.555862,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":62651.0,"Objects":[{"StartTime":62651.0,"EndTime":62651.0,"X":291.0,"Y":147.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":62678.0,"EndTime":62678.0,"X":256.037964,"Y":156.462814,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":62762.0,"Objects":[{"StartTime":62762.0,"EndTime":62762.0,"X":276.0,"Y":114.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":62789.0,"EndTime":62789.0,"X":241.446152,"Y":101.1804,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":62874.0,"Objects":[{"StartTime":62874.0,"EndTime":62874.0,"X":283.0,"Y":81.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":62901.0,"EndTime":62901.0,"X":261.449921,"Y":51.34725,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":62985.0,"Objects":[{"StartTime":62985.0,"EndTime":62985.0,"X":365.0,"Y":31.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":63060.0,"EndTime":63060.0,"X":437.0226,"Y":49.0166931,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":63207.0,"Objects":[{"StartTime":63207.0,"EndTime":63207.0,"X":512.0,"Y":169.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":63282.0,"EndTime":63282.0,"X":438.3405,"Y":168.621353,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":63429.0,"Objects":[{"StartTime":63429.0,"EndTime":63429.0,"X":350.0,"Y":107.0}]},{"StartTime":63540.0,"Objects":[{"StartTime":63540.0,"EndTime":63540.0,"X":293.0,"Y":237.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":63615.0,"EndTime":63615.0,"X":277.221954,"Y":163.678436,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":63762.0,"Objects":[{"StartTime":63762.0,"EndTime":63762.0,"X":428.0,"Y":269.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":63948.0,"EndTime":63948.0,"X":287.108154,"Y":253.30072,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":64096.0,"Objects":[{"StartTime":64096.0,"EndTime":64096.0,"X":191.0,"Y":318.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":64171.0,"EndTime":64171.0,"X":204.152679,"Y":384.5369,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":64318.0,"Objects":[{"StartTime":64318.0,"EndTime":64318.0,"X":192.0,"Y":186.0}]},{"StartTime":64429.0,"Objects":[{"StartTime":64429.0,"EndTime":64429.0,"X":135.0,"Y":253.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":64540.0,"EndTime":64540.0,"X":61.6784363,"Y":268.778046,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":64615.0,"EndTime":64615.0,"X":135.0,"Y":253.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":64762.0,"Objects":[{"StartTime":64762.0,"EndTime":64762.0,"X":24.0,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":64892.0,"EndTime":64892.0,"X":146.587021,"Y":69.97707,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":64985.0,"Objects":[{"StartTime":64985.0,"EndTime":64985.0,"X":160.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":65115.0,"EndTime":65115.0,"X":236.668945,"Y":106.661751,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":65207.0,"Objects":[{"StartTime":65207.0,"EndTime":65207.0,"X":276.0,"Y":34.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":65337.0,"EndTime":65337.0,"X":288.3495,"Y":191.015091,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":65429.0,"Objects":[{"StartTime":65429.0,"EndTime":65429.0,"X":291.0,"Y":219.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":65559.0,"EndTime":65559.0,"X":309.903473,"Y":136.769836,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":65651.0,"Objects":[{"StartTime":65651.0,"EndTime":65651.0,"X":381.0,"Y":111.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":65726.0,"EndTime":65726.0,"X":453.075073,"Y":126.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":65874.0,"Objects":[{"StartTime":65874.0,"EndTime":65874.0,"X":221.0,"Y":163.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":65949.0,"EndTime":65949.0,"X":152.07077,"Y":150.219,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":66096.0,"Objects":[{"StartTime":66096.0,"EndTime":66096.0,"X":41.0,"Y":231.0}]},{"StartTime":66207.0,"Objects":[{"StartTime":66207.0,"EndTime":66207.0,"X":49.0,"Y":267.0}]},{"StartTime":66318.0,"Objects":[{"StartTime":66318.0,"EndTime":66318.0,"X":56.0,"Y":303.0}]},{"StartTime":66429.0,"Objects":[{"StartTime":66429.0,"EndTime":66429.0,"X":67.0,"Y":288.0}]},{"StartTime":66540.0,"Objects":[{"StartTime":66540.0,"EndTime":66540.0,"X":77.0,"Y":270.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":66837.0,"EndTime":66837.0,"X":83.81238,"Y":364.51593,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":66985.0,"Objects":[{"StartTime":66985.0,"EndTime":66985.0,"X":95.0,"Y":356.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67171.0,"EndTime":67171.0,"X":169.229614,"Y":345.277954,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":67318.0,"Objects":[{"StartTime":67318.0,"EndTime":67318.0,"X":274.0,"Y":286.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67393.0,"EndTime":67393.0,"X":276.6237,"Y":355.8248,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":67540.0,"Objects":[{"StartTime":67540.0,"EndTime":67540.0,"X":191.0,"Y":227.0}]},{"StartTime":67651.0,"Objects":[{"StartTime":67651.0,"EndTime":67651.0,"X":255.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67706.0,"EndTime":67706.0,"X":261.337677,"Y":131.382233,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67726.0,"EndTime":67726.0,"X":255.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":67874.0,"Objects":[{"StartTime":67874.0,"EndTime":67874.0,"X":147.0,"Y":83.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67911.0,"EndTime":67911.0,"X":153.740753,"Y":107.0741,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67948.0,"EndTime":67948.0,"X":147.0,"Y":83.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67949.0,"EndTime":67949.0,"X":153.740753,"Y":107.0741,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":68096.0,"Objects":[{"StartTime":68096.0,"EndTime":68096.0,"X":80.0,"Y":148.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":68171.0,"EndTime":68171.0,"X":97.28498,"Y":220.981018,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":68318.0,"Objects":[{"StartTime":68318.0,"EndTime":68318.0,"X":125.0,"Y":356.0}]},{"StartTime":68429.0,"Objects":[{"StartTime":68429.0,"EndTime":68429.0,"X":0.0,"Y":319.0}]},{"StartTime":68540.0,"Objects":[{"StartTime":68540.0,"EndTime":68540.0,"X":0.0,"Y":319.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":68615.0,"EndTime":68615.0,"X":71.24443,"Y":295.564331,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":68762.0,"Objects":[{"StartTime":68762.0,"EndTime":68762.0,"X":277.0,"Y":219.0}]},{"StartTime":68874.0,"Objects":[{"StartTime":68874.0,"EndTime":68874.0,"X":277.0,"Y":219.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":69060.0,"EndTime":69060.0,"X":216.0241,"Y":166.850388,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":69207.0,"Objects":[{"StartTime":69207.0,"EndTime":69207.0,"X":157.0,"Y":273.0}]},{"StartTime":69318.0,"Objects":[{"StartTime":69318.0,"EndTime":69318.0,"X":175.0,"Y":316.0}]},{"StartTime":69429.0,"Objects":[{"StartTime":69429.0,"EndTime":69429.0,"X":212.0,"Y":334.0}]},{"StartTime":69540.0,"Objects":[{"StartTime":69540.0,"EndTime":69540.0,"X":254.0,"Y":333.0}]},{"StartTime":69651.0,"Objects":[{"StartTime":69651.0,"EndTime":69651.0,"X":332.0,"Y":268.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":69678.0,"EndTime":69678.0,"X":334.774872,"Y":230.961487,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":69762.0,"Objects":[{"StartTime":69762.0,"EndTime":69762.0,"X":373.0,"Y":265.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":69789.0,"EndTime":69789.0,"X":392.524567,"Y":235.296951,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":69874.0,"Objects":[{"StartTime":69874.0,"EndTime":69874.0,"X":413.0,"Y":284.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":69901.0,"EndTime":69901.0,"X":444.2059,"Y":266.886841,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":69985.0,"Objects":[{"StartTime":69985.0,"EndTime":69985.0,"X":433.0,"Y":318.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":70012.0,"EndTime":70012.0,"X":467.539429,"Y":329.4084,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":70096.0,"Objects":[{"StartTime":70096.0,"EndTime":70096.0,"X":401.0,"Y":384.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":70171.0,"EndTime":70171.0,"X":332.06134,"Y":363.130951,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":70318.0,"Objects":[{"StartTime":70318.0,"EndTime":70318.0,"X":251.0,"Y":251.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":70393.0,"EndTime":70393.0,"X":244.703979,"Y":178.914719,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":70540.0,"Objects":[{"StartTime":70540.0,"EndTime":70540.0,"X":401.0,"Y":18.0}]},{"StartTime":70651.0,"Objects":[{"StartTime":70651.0,"EndTime":70651.0,"X":401.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":70726.0,"EndTime":70726.0,"X":398.149,"Y":89.727005,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":70874.0,"Objects":[{"StartTime":70874.0,"EndTime":70874.0,"X":327.0,"Y":193.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":71060.0,"EndTime":71060.0,"X":303.965668,"Y":44.77916,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":71207.0,"Objects":[{"StartTime":71207.0,"EndTime":71207.0,"X":290.0,"Y":26.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":71282.0,"EndTime":71282.0,"X":305.8338,"Y":129.799271,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":71429.0,"Objects":[{"StartTime":71429.0,"EndTime":71429.0,"X":272.0,"Y":302.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":71504.0,"EndTime":71504.0,"X":197.997055,"Y":289.811279,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":71651.0,"Objects":[{"StartTime":71651.0,"EndTime":71651.0,"X":33.0,"Y":217.0}]},{"StartTime":71762.0,"Objects":[{"StartTime":71762.0,"EndTime":71762.0,"X":27.0,"Y":187.0}]},{"StartTime":71874.0,"Objects":[{"StartTime":71874.0,"EndTime":71874.0,"X":20.0,"Y":157.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":72004.0,"EndTime":72004.0,"X":145.504913,"Y":80.92537,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":72096.0,"Objects":[{"StartTime":72096.0,"EndTime":72096.0,"X":145.0,"Y":82.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":72171.0,"EndTime":72171.0,"X":200.993164,"Y":76.63079,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":72318.0,"Objects":[{"StartTime":72318.0,"EndTime":72318.0,"X":336.0,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":72448.0,"EndTime":72448.0,"X":263.034,"Y":231.6351,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":72540.0,"Objects":[{"StartTime":72540.0,"EndTime":72540.0,"X":263.0,"Y":232.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":72615.0,"EndTime":72615.0,"X":275.1168,"Y":286.929474,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":72762.0,"Objects":[{"StartTime":72762.0,"EndTime":72762.0,"X":183.0,"Y":384.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":72837.0,"EndTime":72837.0,"X":175.045044,"Y":328.315338,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":72985.0,"Objects":[{"StartTime":72985.0,"EndTime":72985.0,"X":37.0,"Y":140.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":73171.0,"EndTime":73171.0,"X":63.65956,"Y":142.217728,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":73651.0,"Objects":[{"StartTime":73651.0,"EndTime":73651.0,"X":275.0,"Y":372.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":73837.0,"EndTime":73837.0,"X":380.158661,"Y":364.386444,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":74096.0,"Objects":[{"StartTime":74096.0,"EndTime":74096.0,"X":380.0,"Y":364.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":74171.0,"EndTime":74171.0,"X":435.9299,"Y":358.0075,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":74318.0,"Objects":[{"StartTime":74318.0,"EndTime":74318.0,"X":495.0,"Y":271.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":74393.0,"EndTime":74393.0,"X":439.413177,"Y":279.61203,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":74540.0,"Objects":[{"StartTime":74540.0,"EndTime":74540.0,"X":339.0,"Y":270.0}]},{"StartTime":74651.0,"Objects":[{"StartTime":74651.0,"EndTime":74651.0,"X":339.0,"Y":270.0}]},{"StartTime":74762.0,"Objects":[{"StartTime":74762.0,"EndTime":74762.0,"X":339.0,"Y":270.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":74837.0,"EndTime":74837.0,"X":331.467133,"Y":214.256668,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":74985.0,"Objects":[{"StartTime":74985.0,"EndTime":74985.0,"X":408.0,"Y":46.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":75060.0,"EndTime":75060.0,"X":396.112518,"Y":100.979546,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":75207.0,"Objects":[{"StartTime":75207.0,"EndTime":75207.0,"X":220.0,"Y":230.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":75282.0,"EndTime":75282.0,"X":211.729385,"Y":174.361359,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":75429.0,"Objects":[{"StartTime":75429.0,"EndTime":75429.0,"X":282.0,"Y":7.0}]},{"StartTime":75540.0,"Objects":[{"StartTime":75540.0,"EndTime":75540.0,"X":300.0,"Y":98.0}]},{"StartTime":75651.0,"Objects":[{"StartTime":75651.0,"EndTime":75651.0,"X":197.0,"Y":25.0}]},{"StartTime":75762.0,"Objects":[{"StartTime":75762.0,"EndTime":75762.0,"X":222.0,"Y":103.0}]},{"StartTime":75874.0,"Objects":[{"StartTime":75874.0,"EndTime":75874.0,"X":126.0,"Y":69.0}]},{"StartTime":75985.0,"Objects":[{"StartTime":75985.0,"EndTime":75985.0,"X":153.0,"Y":134.0}]},{"StartTime":76096.0,"Objects":[{"StartTime":76096.0,"EndTime":76096.0,"X":76.0,"Y":145.0}]},{"StartTime":76207.0,"Objects":[{"StartTime":76207.0,"EndTime":76207.0,"X":116.0,"Y":179.0}]},{"StartTime":76318.0,"Objects":[{"StartTime":76318.0,"EndTime":76318.0,"X":70.0,"Y":222.0}]},{"StartTime":76429.0,"Objects":[{"StartTime":76429.0,"EndTime":76429.0,"X":111.0,"Y":222.0}]},{"StartTime":76540.0,"Objects":[{"StartTime":76540.0,"EndTime":76540.0,"X":134.0,"Y":253.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":76615.0,"EndTime":76615.0,"X":130.626953,"Y":307.164856,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":76762.0,"Objects":[{"StartTime":76762.0,"EndTime":76762.0,"X":21.0,"Y":384.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77059.0,"EndTime":77059.0,"X":236.973511,"Y":377.8362,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":77207.0,"Objects":[{"StartTime":77207.0,"EndTime":77207.0,"X":384.0,"Y":366.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77282.0,"EndTime":77282.0,"X":391.613525,"Y":291.387451,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":77429.0,"Objects":[{"StartTime":77429.0,"EndTime":77429.0,"X":499.0,"Y":62.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77504.0,"EndTime":77504.0,"X":485.8507,"Y":135.838318,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":77651.0,"Objects":[{"StartTime":77651.0,"EndTime":77651.0,"X":507.0,"Y":237.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77781.0,"EndTime":77781.0,"X":403.789581,"Y":202.548569,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":77874.0,"Objects":[{"StartTime":77874.0,"EndTime":77874.0,"X":404.0,"Y":203.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77949.0,"EndTime":77949.0,"X":329.872131,"Y":214.4043,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":78096.0,"Objects":[{"StartTime":78096.0,"EndTime":78096.0,"X":113.0,"Y":212.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":78226.0,"EndTime":78226.0,"X":115.534233,"Y":319.9018,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":78318.0,"Objects":[{"StartTime":78318.0,"EndTime":78318.0,"X":115.0,"Y":319.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":78393.0,"EndTime":78393.0,"X":188.335175,"Y":334.7147,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":78540.0,"Objects":[{"StartTime":78540.0,"EndTime":78540.0,"X":274.0,"Y":371.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":78726.0,"EndTime":78726.0,"X":257.52887,"Y":191.7552,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":78874.0,"Objects":[{"StartTime":78874.0,"EndTime":78874.0,"X":128.0,"Y":139.0}]},{"StartTime":78985.0,"Objects":[{"StartTime":78985.0,"EndTime":78985.0,"X":128.0,"Y":139.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":79060.0,"EndTime":79060.0,"X":202.567627,"Y":130.958389,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":79207.0,"Objects":[{"StartTime":79207.0,"EndTime":79207.0,"X":365.0,"Y":34.0}]},{"StartTime":79318.0,"Objects":[{"StartTime":79318.0,"EndTime":79318.0,"X":430.0,"Y":114.0}]},{"StartTime":79429.0,"Objects":[{"StartTime":79429.0,"EndTime":79429.0,"X":361.0,"Y":184.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":79559.0,"EndTime":79559.0,"X":277.9352,"Y":126.286682,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":79651.0,"Objects":[{"StartTime":79651.0,"EndTime":79651.0,"X":278.0,"Y":126.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":79726.0,"EndTime":79726.0,"X":203.2309,"Y":131.880722,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":79874.0,"Objects":[{"StartTime":79874.0,"EndTime":79874.0,"X":64.0,"Y":263.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":80004.0,"EndTime":80004.0,"X":47.32452,"Y":160.905121,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":80096.0,"Objects":[{"StartTime":80096.0,"EndTime":80096.0,"X":66.0,"Y":119.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":80171.0,"EndTime":80171.0,"X":77.40429,"Y":193.127869,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":80318.0,"Objects":[{"StartTime":80318.0,"EndTime":80318.0,"X":71.0,"Y":361.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":80504.0,"EndTime":80504.0,"X":231.5679,"Y":298.4993,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":80651.0,"Objects":[{"StartTime":80651.0,"EndTime":80651.0,"X":302.0,"Y":247.0}]},{"StartTime":80762.0,"Objects":[{"StartTime":80762.0,"EndTime":80762.0,"X":222.0,"Y":211.0}]},{"StartTime":80985.0,"Objects":[{"StartTime":80985.0,"EndTime":80985.0,"X":478.0,"Y":344.0}]},{"StartTime":81096.0,"Objects":[{"StartTime":81096.0,"EndTime":81096.0,"X":491.0,"Y":309.0}]},{"StartTime":81207.0,"Objects":[{"StartTime":81207.0,"EndTime":81207.0,"X":498.0,"Y":265.0}]},{"StartTime":81318.0,"Objects":[{"StartTime":81318.0,"EndTime":81318.0,"X":485.0,"Y":223.0}]},{"StartTime":81429.0,"Objects":[{"StartTime":81429.0,"EndTime":81429.0,"X":458.0,"Y":179.0}]},{"StartTime":81540.0,"Objects":[{"StartTime":81540.0,"EndTime":81540.0,"X":418.0,"Y":147.0}]},{"StartTime":81651.0,"Objects":[{"StartTime":81651.0,"EndTime":81651.0,"X":352.0,"Y":126.0}]},{"StartTime":81762.0,"Objects":[{"StartTime":81762.0,"EndTime":81762.0,"X":281.0,"Y":149.0}]},{"StartTime":81874.0,"Objects":[{"StartTime":81874.0,"EndTime":81874.0,"X":239.0,"Y":221.0}]},{"StartTime":81985.0,"Objects":[{"StartTime":81985.0,"EndTime":81985.0,"X":159.0,"Y":262.0}]},{"StartTime":82096.0,"Objects":[{"StartTime":82096.0,"EndTime":82096.0,"X":66.0,"Y":234.0}]},{"StartTime":82207.0,"Objects":[{"StartTime":82207.0,"EndTime":82207.0,"X":11.0,"Y":145.0}]},{"StartTime":82318.0,"Objects":[{"StartTime":82318.0,"EndTime":82318.0,"X":55.0,"Y":33.0}]},{"StartTime":82540.0,"Objects":[{"StartTime":82540.0,"EndTime":82540.0,"X":273.0,"Y":44.0}]},{"StartTime":82651.0,"Objects":[{"StartTime":82651.0,"EndTime":82651.0,"X":320.0,"Y":103.0}]},{"StartTime":82762.0,"Objects":[{"StartTime":82762.0,"EndTime":82762.0,"X":394.0,"Y":118.0}]},{"StartTime":82874.0,"Objects":[{"StartTime":82874.0,"EndTime":82874.0,"X":468.0,"Y":100.0}]},{"StartTime":82985.0,"Objects":[{"StartTime":82985.0,"EndTime":82985.0,"X":507.0,"Y":36.0}]},{"StartTime":83207.0,"Objects":[{"StartTime":83207.0,"EndTime":83207.0,"X":495.0,"Y":19.0}]},{"StartTime":83318.0,"Objects":[{"StartTime":83318.0,"EndTime":83318.0,"X":335.0,"Y":83.0}]},{"StartTime":83429.0,"Objects":[{"StartTime":83429.0,"EndTime":83429.0,"X":453.0,"Y":81.0}]},{"StartTime":83540.0,"Objects":[{"StartTime":83540.0,"EndTime":83540.0,"X":283.0,"Y":24.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":83689.0,"EndTime":83689.0,"X":154.807114,"Y":79.37676,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":83874.0,"Objects":[{"StartTime":83874.0,"EndTime":83874.0,"X":60.0,"Y":238.0}]},{"StartTime":83985.0,"Objects":[{"StartTime":83985.0,"EndTime":83985.0,"X":21.0,"Y":164.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":84134.0,"EndTime":84134.0,"X":160.221619,"Y":176.913727,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":84318.0,"Objects":[{"StartTime":84318.0,"EndTime":84318.0,"X":252.0,"Y":206.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":84393.0,"EndTime":84393.0,"X":268.054169,"Y":136.0318,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":84540.0,"Objects":[{"StartTime":84540.0,"EndTime":84540.0,"X":139.0,"Y":257.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":84615.0,"EndTime":84615.0,"X":140.643234,"Y":328.635956,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":84762.0,"Objects":[{"StartTime":84762.0,"EndTime":84762.0,"X":240.0,"Y":379.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":84892.0,"EndTime":84892.0,"X":311.649658,"Y":350.824829,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":84985.0,"Objects":[{"StartTime":84985.0,"EndTime":84985.0,"X":312.0,"Y":351.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":85060.0,"EndTime":85060.0,"X":270.3759,"Y":292.868469,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":85207.0,"Objects":[{"StartTime":85207.0,"EndTime":85207.0,"X":359.0,"Y":165.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":85337.0,"EndTime":85337.0,"X":372.53064,"Y":264.7404,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":85429.0,"Objects":[{"StartTime":85429.0,"EndTime":85429.0,"X":373.0,"Y":265.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":85504.0,"EndTime":85504.0,"X":446.400818,"Y":280.405121,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":85651.0,"Objects":[{"StartTime":85651.0,"EndTime":85651.0,"X":498.0,"Y":139.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":85837.0,"EndTime":85837.0,"X":394.23114,"Y":13.5172443,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":85985.0,"Objects":[{"StartTime":85985.0,"EndTime":85985.0,"X":394.0,"Y":13.0}]},{"StartTime":86096.0,"Objects":[{"StartTime":86096.0,"EndTime":86096.0,"X":301.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86171.0,"EndTime":86171.0,"X":226.398117,"Y":84.28256,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":86318.0,"Objects":[{"StartTime":86318.0,"EndTime":86318.0,"X":66.0,"Y":66.0}]},{"StartTime":86429.0,"Objects":[{"StartTime":86429.0,"EndTime":86429.0,"X":13.0,"Y":136.0}]},{"StartTime":86540.0,"Objects":[{"StartTime":86540.0,"EndTime":86540.0,"X":72.0,"Y":193.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86670.0,"EndTime":86670.0,"X":176.5301,"Y":191.3274,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":86762.0,"Objects":[{"StartTime":86762.0,"EndTime":86762.0,"X":176.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86837.0,"EndTime":86837.0,"X":153.167648,"Y":261.1704,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":86985.0,"Objects":[{"StartTime":86985.0,"EndTime":86985.0,"X":309.0,"Y":370.0}]},{"StartTime":87096.0,"Objects":[{"StartTime":87096.0,"EndTime":87096.0,"X":359.0,"Y":310.0}]},{"StartTime":87207.0,"Objects":[{"StartTime":87207.0,"EndTime":87207.0,"X":283.0,"Y":297.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87282.0,"EndTime":87282.0,"X":210.457672,"Y":316.042358,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":87429.0,"Objects":[{"StartTime":87429.0,"EndTime":87429.0,"X":4.0,"Y":203.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87615.0,"EndTime":87615.0,"X":127.740509,"Y":264.675873,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":87762.0,"Objects":[{"StartTime":87762.0,"EndTime":87762.0,"X":238.0,"Y":217.0}]},{"StartTime":87874.0,"Objects":[{"StartTime":87874.0,"EndTime":87874.0,"X":183.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87949.0,"EndTime":87949.0,"X":108.341415,"Y":112.851837,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88096.0,"Objects":[{"StartTime":88096.0,"EndTime":88096.0,"X":98.0,"Y":33.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":88171.0,"EndTime":88171.0,"X":23.3245468,"Y":26.03029,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88318.0,"Objects":[{"StartTime":88318.0,"EndTime":88318.0,"X":306.0,"Y":182.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":88393.0,"EndTime":88393.0,"X":380.658569,"Y":174.851837,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88540.0,"Objects":[{"StartTime":88540.0,"EndTime":88540.0,"X":391.0,"Y":95.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":88615.0,"EndTime":88615.0,"X":465.6667,"Y":87.9369354,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88762.0,"Objects":[{"StartTime":88762.0,"EndTime":88762.0,"X":232.0,"Y":28.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":88789.0,"EndTime":88789.0,"X":225.089172,"Y":64.85771,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88874.0,"Objects":[{"StartTime":88874.0,"EndTime":88874.0,"X":243.0,"Y":39.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":88901.0,"EndTime":88901.0,"X":236.089172,"Y":75.85771,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88985.0,"Objects":[{"StartTime":88985.0,"EndTime":88985.0,"X":256.0,"Y":50.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89012.0,"EndTime":89012.0,"X":250.978073,"Y":87.16222,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":89207.0,"Objects":[{"StartTime":89207.0,"EndTime":89207.0,"X":485.0,"Y":87.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89262.0,"EndTime":89262.0,"X":493.085876,"Y":50.6135063,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89318.0,"EndTime":89318.0,"X":485.049,"Y":86.77947,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89337.0,"EndTime":89337.0,"X":493.1349,"Y":50.3929863,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":89429.0,"Objects":[{"StartTime":89429.0,"EndTime":89429.0,"X":396.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89504.0,"EndTime":89504.0,"X":410.34082,"Y":193.61618,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":89651.0,"Objects":[{"StartTime":89651.0,"EndTime":89651.0,"X":471.0,"Y":317.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89837.0,"EndTime":89837.0,"X":330.6769,"Y":326.25708,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":90096.0,"Objects":[{"StartTime":90096.0,"EndTime":90096.0,"X":61.0,"Y":239.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":90282.0,"EndTime":90282.0,"X":201.32309,"Y":248.2571,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":90540.0,"Objects":[{"StartTime":90540.0,"EndTime":90540.0,"X":367.0,"Y":21.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":90615.0,"EndTime":90615.0,"X":328.4434,"Y":82.729866,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":90762.0,"Objects":[{"StartTime":90762.0,"EndTime":90762.0,"X":163.0,"Y":96.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":90837.0,"EndTime":90837.0,"X":201.556625,"Y":157.729874,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":90985.0,"Objects":[{"StartTime":90985.0,"EndTime":90985.0,"X":190.0,"Y":346.0}]},{"StartTime":91096.0,"Objects":[{"StartTime":91096.0,"EndTime":91096.0,"X":328.0,"Y":272.0}]},{"StartTime":91207.0,"Objects":[{"StartTime":91207.0,"EndTime":91207.0,"X":154.0,"Y":272.0}]},{"StartTime":91318.0,"Objects":[{"StartTime":91318.0,"EndTime":91318.0,"X":365.0,"Y":338.0}]},{"StartTime":91429.0,"Objects":[{"StartTime":91429.0,"EndTime":91429.0,"X":257.0,"Y":382.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":91615.0,"EndTime":91615.0,"X":259.5329,"Y":236.323334,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":91762.0,"Objects":[{"StartTime":91762.0,"EndTime":91762.0,"X":325.0,"Y":196.0}]},{"StartTime":91874.0,"Objects":[{"StartTime":91874.0,"EndTime":91874.0,"X":325.0,"Y":196.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":92004.0,"EndTime":92004.0,"X":429.818634,"Y":189.708939,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":92096.0,"Objects":[{"StartTime":92096.0,"EndTime":92096.0,"X":430.0,"Y":190.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":92171.0,"EndTime":92171.0,"X":418.874451,"Y":115.829781,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":92318.0,"Objects":[{"StartTime":92318.0,"EndTime":92318.0,"X":313.0,"Y":19.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":92448.0,"EndTime":92448.0,"X":201.559357,"Y":34.4023666,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":92540.0,"Objects":[{"StartTime":92540.0,"EndTime":92540.0,"X":201.0,"Y":34.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":92615.0,"EndTime":92615.0,"X":212.6055,"Y":108.096649,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":92762.0,"Objects":[{"StartTime":92762.0,"EndTime":92762.0,"X":209.0,"Y":252.0}]},{"StartTime":92874.0,"Objects":[{"StartTime":92874.0,"EndTime":92874.0,"X":156.0,"Y":261.0}]},{"StartTime":92985.0,"Objects":[{"StartTime":92985.0,"EndTime":92985.0,"X":112.0,"Y":231.0}]},{"StartTime":93096.0,"Objects":[{"StartTime":93096.0,"EndTime":93096.0,"X":60.0,"Y":222.0}]},{"StartTime":93207.0,"Objects":[{"StartTime":93207.0,"EndTime":93207.0,"X":13.0,"Y":247.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":93282.0,"EndTime":93282.0,"X":13.18664,"Y":318.8803,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":93429.0,"Objects":[{"StartTime":93429.0,"EndTime":93429.0,"X":173.0,"Y":186.0}]},{"StartTime":93540.0,"Objects":[{"StartTime":93540.0,"EndTime":93540.0,"X":215.0,"Y":120.0}]},{"StartTime":93651.0,"Objects":[{"StartTime":93651.0,"EndTime":93651.0,"X":162.0,"Y":49.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":93726.0,"EndTime":93726.0,"X":90.69035,"Y":49.0886574,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":93874.0,"Objects":[{"StartTime":93874.0,"EndTime":93874.0,"X":234.0,"Y":138.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":93949.0,"EndTime":93949.0,"X":303.1729,"Y":152.77562,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":94096.0,"Objects":[{"StartTime":94096.0,"EndTime":94096.0,"X":385.0,"Y":39.0}]},{"StartTime":94318.0,"Objects":[{"StartTime":94318.0,"EndTime":94318.0,"X":337.0,"Y":286.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":94393.0,"EndTime":94393.0,"X":324.257,"Y":359.909515,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":94540.0,"Objects":[{"StartTime":94540.0,"EndTime":94540.0,"X":409.0,"Y":327.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":94837.0,"EndTime":94837.0,"X":285.0893,"Y":225.489716,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":94985.0,"Objects":[{"StartTime":94985.0,"EndTime":94985.0,"X":239.0,"Y":319.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":95060.0,"EndTime":95060.0,"X":190.987534,"Y":371.081482,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":95207.0,"Objects":[{"StartTime":95207.0,"EndTime":95207.0,"X":34.0,"Y":344.0}]},{"StartTime":95318.0,"Objects":[{"StartTime":95318.0,"EndTime":95318.0,"X":21.0,"Y":309.0}]},{"StartTime":95429.0,"Objects":[{"StartTime":95429.0,"EndTime":95429.0,"X":14.0,"Y":265.0}]},{"StartTime":95540.0,"Objects":[{"StartTime":95540.0,"EndTime":95540.0,"X":27.0,"Y":223.0}]},{"StartTime":95651.0,"Objects":[{"StartTime":95651.0,"EndTime":95651.0,"X":54.0,"Y":179.0}]},{"StartTime":95762.0,"Objects":[{"StartTime":95762.0,"EndTime":95762.0,"X":94.0,"Y":147.0}]},{"StartTime":95873.0,"Objects":[{"StartTime":95873.0,"EndTime":95873.0,"X":160.0,"Y":126.0}]},{"StartTime":95984.0,"Objects":[{"StartTime":95984.0,"EndTime":95984.0,"X":231.0,"Y":149.0}]},{"StartTime":96096.0,"Objects":[{"StartTime":96096.0,"EndTime":96096.0,"X":273.0,"Y":221.0}]},{"StartTime":96207.0,"Objects":[{"StartTime":96207.0,"EndTime":96207.0,"X":353.0,"Y":262.0}]},{"StartTime":96318.0,"Objects":[{"StartTime":96318.0,"EndTime":96318.0,"X":446.0,"Y":234.0}]},{"StartTime":96429.0,"Objects":[{"StartTime":96429.0,"EndTime":96429.0,"X":501.0,"Y":145.0}]},{"StartTime":96540.0,"Objects":[{"StartTime":96540.0,"EndTime":96540.0,"X":450.0,"Y":36.0}]},{"StartTime":96762.0,"Objects":[{"StartTime":96762.0,"EndTime":96762.0,"X":239.0,"Y":44.0}]},{"StartTime":96873.0,"Objects":[{"StartTime":96873.0,"EndTime":96873.0,"X":192.0,"Y":103.0}]},{"StartTime":96984.0,"Objects":[{"StartTime":96984.0,"EndTime":96984.0,"X":118.0,"Y":118.0}]},{"StartTime":97096.0,"Objects":[{"StartTime":97096.0,"EndTime":97096.0,"X":44.0,"Y":100.0}]},{"StartTime":97207.0,"Objects":[{"StartTime":97207.0,"EndTime":97207.0,"X":5.0,"Y":36.0}]},{"StartTime":97429.0,"Objects":[{"StartTime":97429.0,"EndTime":97429.0,"X":17.0,"Y":19.0}]},{"StartTime":97540.0,"Objects":[{"StartTime":97540.0,"EndTime":97540.0,"X":146.0,"Y":51.0}]},{"StartTime":97651.0,"Objects":[{"StartTime":97651.0,"EndTime":97651.0,"X":29.0,"Y":122.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":97781.0,"EndTime":97781.0,"X":36.8451042,"Y":177.700241,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":97874.0,"Objects":[{"StartTime":97874.0,"EndTime":97874.0,"X":44.0,"Y":197.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":97949.0,"EndTime":97949.0,"X":144.239655,"Y":225.070267,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":98096.0,"Objects":[{"StartTime":98096.0,"EndTime":98096.0,"X":301.0,"Y":160.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":98226.0,"EndTime":98226.0,"X":377.703552,"Y":135.967728,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":98318.0,"Objects":[{"StartTime":98318.0,"EndTime":98318.0,"X":398.0,"Y":147.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":98393.0,"EndTime":98393.0,"X":420.6091,"Y":246.7476,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":98540.0,"Objects":[{"StartTime":98540.0,"EndTime":98540.0,"X":265.0,"Y":371.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":98615.0,"EndTime":98615.0,"X":190.5137,"Y":362.2369,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":98762.0,"Objects":[{"StartTime":98762.0,"EndTime":98762.0,"X":127.0,"Y":202.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":98837.0,"EndTime":98837.0,"X":138.654449,"Y":127.911041,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":98985.0,"Objects":[{"StartTime":98985.0,"EndTime":98985.0,"X":193.0,"Y":260.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":99115.0,"EndTime":99115.0,"X":91.16292,"Y":289.2349,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":99207.0,"Objects":[{"StartTime":99207.0,"EndTime":99207.0,"X":91.0,"Y":290.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":99282.0,"EndTime":99282.0,"X":80.26821,"Y":364.2282,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":99429.0,"Objects":[{"StartTime":99429.0,"EndTime":99429.0,"X":20.0,"Y":184.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":99559.0,"EndTime":99559.0,"X":23.5322342,"Y":77.30794,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":99651.0,"Objects":[{"StartTime":99651.0,"EndTime":99651.0,"X":23.0,"Y":78.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":99726.0,"EndTime":99726.0,"X":97.15753,"Y":89.20986,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":99874.0,"Objects":[{"StartTime":99874.0,"EndTime":99874.0,"X":271.0,"Y":74.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":99949.0,"EndTime":99949.0,"X":231.432739,"Y":15.2187185,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":100096.0,"Objects":[{"StartTime":100096.0,"EndTime":100096.0,"X":186.0,"Y":180.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":100171.0,"EndTime":100171.0,"X":253.138412,"Y":157.416565,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":100318.0,"Objects":[{"StartTime":100318.0,"EndTime":100318.0,"X":132.0,"Y":63.0}]},{"StartTime":100540.0,"Objects":[{"StartTime":100540.0,"EndTime":100540.0,"X":253.0,"Y":157.0}]},{"StartTime":100651.0,"Objects":[{"StartTime":100651.0,"EndTime":100651.0,"X":285.0,"Y":167.0}]},{"StartTime":100762.0,"Objects":[{"StartTime":100762.0,"EndTime":100762.0,"X":357.0,"Y":129.0}]},{"StartTime":100873.0,"Objects":[{"StartTime":100873.0,"EndTime":100873.0,"X":389.0,"Y":139.0}]},{"StartTime":100985.0,"Objects":[{"StartTime":100985.0,"EndTime":100985.0,"X":422.0,"Y":148.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":101060.0,"EndTime":101060.0,"X":410.5588,"Y":219.494064,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":101207.0,"Objects":[{"StartTime":101207.0,"EndTime":101207.0,"X":459.0,"Y":377.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":101282.0,"EndTime":101282.0,"X":465.133026,"Y":305.73877,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":101429.0,"Objects":[{"StartTime":101429.0,"EndTime":101429.0,"X":398.0,"Y":242.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":101504.0,"EndTime":101504.0,"X":324.167938,"Y":255.1843,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":101651.0,"Objects":[{"StartTime":101651.0,"EndTime":101651.0,"X":165.0,"Y":354.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":101948.0,"EndTime":101948.0,"X":197.2489,"Y":249.27066,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":102096.0,"Objects":[{"StartTime":102096.0,"EndTime":102096.0,"X":302.0,"Y":165.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":102171.0,"EndTime":102171.0,"X":292.2159,"Y":90.64093,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":102318.0,"Objects":[{"StartTime":102318.0,"EndTime":102318.0,"X":392.0,"Y":91.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":102393.0,"EndTime":102393.0,"X":382.340851,"Y":16.6245956,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":102540.0,"Objects":[{"StartTime":102540.0,"EndTime":102540.0,"X":192.0,"Y":229.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":102615.0,"EndTime":102615.0,"X":207.768524,"Y":155.676361,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":102762.0,"Objects":[{"StartTime":102762.0,"EndTime":102762.0,"X":107.0,"Y":172.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":102837.0,"EndTime":102837.0,"X":122.768524,"Y":98.67637,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":102985.0,"Objects":[{"StartTime":102985.0,"EndTime":102985.0,"X":314.0,"Y":332.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103012.0,"EndTime":103012.0,"X":307.835052,"Y":295.010223,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":103096.0,"Objects":[{"StartTime":103096.0,"EndTime":103096.0,"X":343.0,"Y":345.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103123.0,"EndTime":103123.0,"X":336.835052,"Y":308.010223,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":103207.0,"Objects":[{"StartTime":103207.0,"EndTime":103207.0,"X":370.0,"Y":358.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103234.0,"EndTime":103234.0,"X":363.835052,"Y":321.010223,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":103429.0,"Objects":[{"StartTime":103429.0,"EndTime":103429.0,"X":380.0,"Y":117.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103484.0,"EndTime":103484.0,"X":374.728638,"Y":80.1005249,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103540.0,"EndTime":103540.0,"X":379.968048,"Y":116.776367,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103559.0,"EndTime":103559.0,"X":374.6967,"Y":79.87689,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":103651.0,"Objects":[{"StartTime":103651.0,"EndTime":103651.0,"X":444.0,"Y":166.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103726.0,"EndTime":103726.0,"X":378.552429,"Y":197.3988,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":103874.0,"Objects":[{"StartTime":103874.0,"EndTime":103874.0,"X":392.0,"Y":2.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103949.0,"EndTime":103949.0,"X":451.723328,"Y":43.11612,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":104096.0,"Objects":[{"StartTime":104096.0,"EndTime":104096.0,"X":271.0,"Y":129.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104171.0,"EndTime":104171.0,"X":275.70752,"Y":56.6180077,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":104318.0,"Objects":[{"StartTime":104318.0,"EndTime":104318.0,"X":505.0,"Y":113.0}]},{"StartTime":104540.0,"Objects":[{"StartTime":104540.0,"EndTime":104540.0,"X":269.0,"Y":217.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104595.0,"EndTime":104595.0,"X":231.732529,"Y":216.296829,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104651.0,"EndTime":104651.0,"X":268.774139,"Y":216.995743,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104670.0,"EndTime":104670.0,"X":231.506683,"Y":216.292572,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":104762.0,"Objects":[{"StartTime":104762.0,"EndTime":104762.0,"X":360.0,"Y":220.0}]},{"StartTime":104874.0,"Objects":[{"StartTime":104874.0,"EndTime":104874.0,"X":296.0,"Y":384.0}]},{"StartTime":105096.0,"Objects":[{"StartTime":105096.0,"EndTime":105096.0,"X":102.0,"Y":307.0}]},{"StartTime":105207.0,"Objects":[{"StartTime":105207.0,"EndTime":105207.0,"X":102.0,"Y":307.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":105504.0,"EndTime":105504.0,"X":359.15506,"Y":319.9753,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":105651.0,"Objects":[{"StartTime":105651.0,"EndTime":105651.0,"X":439.0,"Y":319.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":105948.0,"EndTime":105948.0,"X":360.576447,"Y":251.735214,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":106096.0,"Objects":[{"StartTime":106096.0,"EndTime":106096.0,"X":373.0,"Y":258.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":106393.0,"EndTime":106393.0,"X":419.839447,"Y":322.692932,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":106651.0,"Objects":[{"StartTime":106651.0,"EndTime":106651.0,"X":420.0,"Y":323.0}]},{"StartTime":106763.0,"Objects":[{"StartTime":106763.0,"EndTime":106763.0,"X":469.0,"Y":245.0}]},{"StartTime":106874.0,"Objects":[{"StartTime":106874.0,"EndTime":106874.0,"X":508.0,"Y":322.0}]},{"StartTime":106985.0,"Objects":[{"StartTime":106985.0,"EndTime":106985.0,"X":379.0,"Y":245.0}]},{"StartTime":107207.0,"Objects":[{"StartTime":107207.0,"EndTime":107207.0,"X":483.0,"Y":105.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":107262.0,"EndTime":107262.0,"X":475.3316,"Y":49.6172256,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":107318.0,"EndTime":107318.0,"X":482.953522,"Y":104.664345,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":107337.0,"EndTime":107337.0,"X":475.285126,"Y":49.28157,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":107429.0,"Objects":[{"StartTime":107429.0,"EndTime":107429.0,"X":462.0,"Y":30.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":107615.0,"EndTime":107615.0,"X":324.7961,"Y":30.4987278,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":107874.0,"Objects":[{"StartTime":107874.0,"EndTime":107874.0,"X":272.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":108060.0,"EndTime":108060.0,"X":134.090317,"Y":110.785133,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":108207.0,"Objects":[{"StartTime":108207.0,"EndTime":108207.0,"X":103.0,"Y":213.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":108393.0,"EndTime":108393.0,"X":243.658188,"Y":205.751328,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":108540.0,"Objects":[{"StartTime":108540.0,"EndTime":108540.0,"X":393.0,"Y":187.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":108615.0,"EndTime":108615.0,"X":386.959076,"Y":261.756317,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":108763.0,"Objects":[{"StartTime":108763.0,"EndTime":108763.0,"X":333.0,"Y":338.0}]},{"StartTime":108874.0,"Objects":[{"StartTime":108874.0,"EndTime":108874.0,"X":467.0,"Y":307.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":108929.0,"EndTime":108929.0,"X":503.151581,"Y":298.3925,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":108949.0,"EndTime":108949.0,"X":467.0,"Y":307.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":109096.0,"Objects":[{"StartTime":109096.0,"EndTime":109096.0,"X":409.0,"Y":380.0}]},{"StartTime":109207.0,"Objects":[{"StartTime":109207.0,"EndTime":109207.0,"X":300.0,"Y":257.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":109282.0,"EndTime":109282.0,"X":277.2678,"Y":187.976425,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":109429.0,"Objects":[{"StartTime":109429.0,"EndTime":109429.0,"X":401.0,"Y":118.0}]},{"StartTime":109651.0,"Objects":[{"StartTime":109651.0,"EndTime":109651.0,"X":401.0,"Y":118.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":109837.0,"EndTime":109837.0,"X":326.407349,"Y":110.193794,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":109985.0,"Objects":[{"StartTime":109985.0,"EndTime":109985.0,"X":256.0,"Y":15.0}]},{"StartTime":110096.0,"Objects":[{"StartTime":110096.0,"EndTime":110096.0,"X":175.0,"Y":121.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":110226.0,"EndTime":110226.0,"X":128.04184,"Y":25.2383614,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":110318.0,"Objects":[{"StartTime":110318.0,"EndTime":110318.0,"X":128.0,"Y":26.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":110448.0,"EndTime":110448.0,"X":68.84166,"Y":114.866982,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":110540.0,"Objects":[{"StartTime":110540.0,"EndTime":110540.0,"X":69.0,"Y":114.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":110670.0,"EndTime":110670.0,"X":175.436081,"Y":123.06649,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":110762.0,"Objects":[{"StartTime":110762.0,"EndTime":110762.0,"X":160.0,"Y":223.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":110837.0,"EndTime":110837.0,"X":52.99624,"Y":242.365173,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":110985.0,"Objects":[{"StartTime":110985.0,"EndTime":110985.0,"X":193.0,"Y":334.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111060.0,"EndTime":111060.0,"X":237.04248,"Y":301.937531,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":111207.0,"Objects":[{"StartTime":111207.0,"EndTime":111207.0,"X":335.0,"Y":325.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111282.0,"EndTime":111282.0,"X":372.85437,"Y":365.2907,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":111429.0,"Objects":[{"StartTime":111429.0,"EndTime":111429.0,"X":273.0,"Y":383.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111726.0,"EndTime":111726.0,"X":303.272858,"Y":216.98761,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":111874.0,"Objects":[{"StartTime":111874.0,"EndTime":111874.0,"X":383.0,"Y":255.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111949.0,"EndTime":111949.0,"X":454.046539,"Y":273.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":112096.0,"Objects":[{"StartTime":112096.0,"EndTime":112096.0,"X":209.0,"Y":219.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":112171.0,"EndTime":112171.0,"X":136.491043,"Y":208.167511,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":112318.0,"Objects":[{"StartTime":112318.0,"EndTime":112318.0,"X":403.0,"Y":147.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":112615.0,"EndTime":112615.0,"X":236.823761,"Y":114.722252,"StackOffset":{"X":0.0,"Y":0.0}}]}]} \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/1341554.osu b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/1341554.osu new file mode 100644 index 000000000000..e118bc2db2db --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/1341554.osu @@ -0,0 +1,941 @@ +osu file format v14 + +[General] +AudioLeadIn: 0 +PreviewTime: 76429 +Countdown: 0 +SampleSet: Soft +StackLeniency: 0.2 +Mode: 0 +LetterboxInBreaks: 0 +WidescreenStoryboard: 1 + +[Difficulty] +HPDrainRate:5 +CircleSize:4.3 +OverallDifficulty:8 +ApproachRate:9.3 +SliderMultiplier:2.99999995231628 +SliderTickRate:1 + +[Events] +//Background and Video events +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples + +[TimingPoints] +763,444.444444444444,4,2,1,60,1,0 +763,-111.111111111111,4,2,1,60,0,0 +1929,-100,4,2,1,5,0,0 +1985,-100,4,2,1,60,0,0 +2040,-100,4,2,1,5,0,0 +2096,-153.846153846153,4,2,1,60,0,0 +2429,-133.333333333333,4,2,1,5,0,0 +2540,-71.4285714285714,4,2,1,70,0,1 +2985,-100,4,2,1,70,0,1 +4485,-100,4,2,1,5,0,1 +4540,-100,4,2,1,70,0,1 +4707,-100,4,2,1,5,0,1 +4762,-100,4,2,1,70,0,1 +4929,-100,4,2,1,5,0,1 +4985,-100,4,2,1,70,0,1 +5096,-83.3333333333333,4,2,1,70,0,1 +5429,-133.333333333333,4,2,1,70,0,1 +5596,-133.333333333333,4,2,1,70,0,1 +5652,-133.333333333333,4,2,1,70,0,1 +5818,-133.333333333333,4,2,1,70,0,1 +5874,-133.333333333333,4,2,1,70,0,1 +6040,-133.333333333333,4,2,1,70,0,1 +6096,-100,4,2,1,70,0,1 +6540,-100,4,2,1,70,0,1 +8040,-100,4,2,1,5,0,1 +8096,-100,4,2,1,70,0,1 +8262,-100,4,2,1,5,0,1 +8318,-100,4,2,1,70,0,1 +8485,-100,4,2,1,5,0,1 +8540,-133.333333333333,4,2,1,70,0,1 +8874,-100,4,2,1,5,0,1 +8985,-100,4,2,1,70,0,1 +9651,-100,4,2,1,70,0,1 +10096,-100,4,2,1,70,0,1 +11596,-100,4,2,1,5,0,1 +11651,-100,4,2,1,70,0,1 +11818,-100,4,2,1,5,0,1 +11873,-100,4,2,1,70,0,1 +11874,-80,4,2,1,70,0,1 +12040,-80,4,2,1,5,0,1 +12096,-80,4,2,1,70,0,1 +12207,-133.333333333333,4,2,1,70,0,1 +12429,-100,4,2,1,5,0,1 +12540,-100,4,2,1,70,0,1 +12707,-100,4,2,1,70,0,1 +12763,-100,4,2,1,70,0,1 +12929,-100,4,2,1,70,0,1 +12985,-100,4,2,1,70,0,1 +13429,-300,4,2,1,70,0,1 +13651,-83.3333333333333,4,2,1,70,0,1 +13874,-100,4,2,1,70,0,1 +15151,-100,4,2,1,5,0,1 +15207,-100,4,2,1,70,0,1 +15373,-100,4,2,1,5,0,1 +15429,-100,4,2,1,70,0,1 +15596,-100,4,2,1,5,0,1 +15651,-100,4,2,1,70,0,1 +15985,-100,4,2,1,5,0,1 +16096,-100,4,2,1,70,0,1 +16262,-100,4,2,1,70,0,1 +16318,-83.3333333333333,4,2,1,70,0,1 +16651,-100,4,2,1,70,0,1 +16762,-133.333333333333,4,2,1,60,0,0 +17096,-133.333333333333,4,2,1,5,0,0 +17207,-200,4,2,1,60,0,0 +18096,-66.6666666666667,4,2,1,60,0,0 +18262,-66.6666666666667,4,2,1,5,0,0 +18318,-66.6666666666667,4,2,1,60,0,0 +18540,-100,4,2,1,60,0,0 +18874,-100,4,2,1,5,0,0 +18985,-100,4,2,1,60,0,0 +19985,-100,4,2,1,60,0,0 +20485,-100,4,2,1,5,0,0 +20540,-100,4,2,1,60,0,0 +20707,-100,4,2,1,5,0,0 +20762,-200,4,2,1,60,0,0 +20985,-100,4,2,1,60,0,0 +21095,-100,4,2,1,60,0,0 +21374,-100,4,2,1,5,0,0 +21429,-100,4,2,1,60,0,0 +21596,-100,4,2,1,5,0,0 +21651,-100,4,2,1,60,0,0 +21818,-100,4,2,1,5,0,0 +21874,-66.6666666666667,4,2,1,60,0,0 +21985,-100,4,2,1,60,0,0 +22096,-100,4,2,1,60,0,0 +22985,-200,4,2,1,60,0,0 +23318,-100,4,2,1,60,0,0 +23429,-100,4,2,1,60,0,0 +23540,-100,4,2,1,60,0,0 +23651,-100,4,2,1,60,0,0 +23762,-100,4,2,1,60,0,0 +23874,-133.333333333333,4,2,1,60,0,0 +24208,-133.333333333333,4,2,1,5,0,0 +24318,-200,4,2,1,5,0,0 +24319,-200,4,2,1,60,0,0 +24540,-100,4,2,1,60,0,0 +24651,-66.6666666666667,4,2,1,60,0,0 +24874,-100,4,2,1,60,0,0 +25374,-100,4,2,1,5,0,0 +25429,-100,4,2,1,60,0,0 +27096,-100,4,2,1,60,0,0 +27596,-100,4,2,1,5,0,0 +27651,-100,4,2,1,60,0,0 +27818,-100,4,2,1,5,0,0 +27873,-133.333333333333,4,2,1,60,0,0 +28096,-100,4,2,1,60,0,0 +28206,-100,4,2,1,60,0,0 +28485,-100,4,2,1,5,0,0 +28540,-100,4,2,1,60,0,0 +28707,-100,4,2,1,5,0,0 +28762,-100,4,2,1,60,0,0 +28929,-100,4,2,1,5,0,0 +28985,-66.6666666666667,4,2,1,60,0,0 +29151,-100,4,2,1,60,0,0 +29207,-100,4,2,1,60,0,0 +29651,-100,4,2,1,60,0,0 +30429,-100,4,2,1,60,0,0 +30540,-58.8235294117647,4,2,1,60,0,0 +30874,-58.8235294117647,4,2,1,5,0,0 +30985,-58.8235294117647,4,2,1,60,0,0 +31040,-100,4,2,1,5,0,0 +31429,-100,4,2,1,60,0,0 +32485,-100,4,2,1,60,0,0 +32540,-100,4,2,1,60,0,0 +32707,-100,4,2,1,60,0,0 +32762,-100,4,2,1,60,0,0 +32985,-100,4,2,1,60,0,0 +34318,-50,4,2,1,60,0,0 +34485,-100,4,2,1,5,0,0 +34540,-100,4,2,1,60,0,0 +35151,-100,4,2,1,5,0,0 +35207,-100,4,2,1,60,0,0 +35374,-100,4,2,1,5,0,0 +35430,-100,4,2,1,60,0,0 +35818,-100,4,2,1,5,0,0 +35874,-200,4,2,1,60,0,0 +36429,-100,4,2,1,60,0,0 +37818,-100,4,2,1,5,0,0 +37874,-100,4,2,1,60,0,0 +38040,-100,4,2,1,5,0,0 +38096,-50,4,2,1,60,0,0 +38151,-100,4,2,1,5,0,0 +38540,-100,4,2,1,60,0,0 +39596,-100,4,2,1,5,0,0 +39651,-100,4,2,1,60,0,0 +39818,-100,4,2,1,60,0,0 +39873,-100,4,2,1,60,0,0 +40096,-100,4,2,1,60,0,0 +41429,-50,4,2,1,60,0,0 +41596,-100,4,2,1,5,0,0 +41651,-100,4,2,1,60,0,0 +41818,-100,4,2,1,5,0,0 +41874,-100,4,2,1,60,0,0 +42040,-100,4,2,1,5,0,0 +42096,-100,4,2,1,60,0,0 +44318,-100,4,2,1,60,0,0 +44762,-83.3333333333333,4,2,1,60,0,0 +45207,-66.6666666666667,4,2,1,45,0,0 +45651,-133.333333333333,4,2,1,45,0,0 +51540,-133.333333333333,4,2,1,50,0,0 +51651,-133.333333333333,4,2,1,45,0,0 +52318,-133.333333333333,4,2,1,45,0,0 +58540,-76.9230769230769,4,2,1,45,0,0 +58818,-100,4,2,1,45,0,0 +58874,-111.111111111111,4,2,1,45,0,0 +59318,-111.111111111111,4,2,1,45,0,0 +59429,-83.3333333333333,4,2,1,60,0,0 +59540,-83.3333333333333,4,2,1,5,0,0 +59874,-100,4,2,1,60,0,0 +60096,-100,4,2,1,5,0,0 +60207,-100,4,2,1,60,0,0 +60707,-100,4,2,1,5,0,0 +60763,-100,4,2,1,60,0,0 +60818,-100,4,2,1,5,0,0 +60874,-100,4,2,1,60,0,0 +60929,-100,4,2,1,5,0,0 +60985,-100,4,2,1,60,0,0 +61040,-100,4,2,1,5,0,0 +61096,-100,4,2,1,60,0,0 +61151,-100,4,2,1,5,0,0 +61207,-100,4,2,1,60,0,0 +61596,-100,4,2,1,5,0,0 +61651,-100,4,2,1,60,0,0 +61762,-83.3333333333333,4,2,1,60,0,0 +61985,-100,4,2,1,5,0,0 +62096,-100,4,2,1,60,0,0 +62151,-100,4,2,1,5,0,0 +62207,-100,4,2,1,60,0,0 +62262,-100,4,2,1,5,0,0 +62318,-100,4,2,1,60,0,0 +62374,-100,4,2,1,5,0,0 +62430,-100,4,2,1,60,0,0 +62485,-100,4,2,1,5,0,0 +62540,-100,4,2,1,60,0,0 +62596,-100,4,2,1,5,0,0 +62651,-100,4,2,1,60,0,0 +62707,-100,4,2,1,5,0,0 +62762,-100,4,2,1,60,0,0 +62818,-100,4,2,1,5,0,0 +62874,-100,4,2,1,60,0,0 +62929,-100,4,2,1,60,0,0 +62930,-100,4,2,1,5,0,0 +62985,-100,4,2,1,60,0,0 +63707,-100,4,2,1,5,0,0 +63762,-100,4,2,1,60,0,0 +64262,-100,4,2,1,5,0,0 +64318,-100,4,2,1,60,0,0 +64485,-100,4,2,1,5,0,0 +64540,-100,4,2,1,60,0,0 +64596,-100,4,2,1,5,0,0 +64651,-100,4,2,1,60,0,0 +64707,-100,4,2,1,5,0,0 +64762,-71.4285714285714,4,2,1,60,0,0 +64929,-71.4285714285714,4,2,1,5,0,0 +64984,-133.333333333333,4,2,1,60,0,0 +65151,-133.333333333333,4,2,1,5,0,0 +65206,-71.4285714285714,4,2,1,60,0,0 +65374,-71.4285714285714,4,2,1,5,0,0 +65429,-133.333333333333,4,2,1,60,0,0 +65596,-133.333333333333,4,2,1,5,0,0 +65651,-100,4,2,1,60,0,0 +66540,-66.6666666666667,4,2,1,60,0,0 +66596,-66.6666666666667,4,2,1,5,0,0 +66929,-100,4,2,1,5,0,0 +66985,-200,4,2,1,60,0,0 +67207,-200,4,2,1,5,0,0 +67318,-100,4,2,1,60,0,0 +67818,-100,4,2,1,5,0,0 +67874,-100,4,2,1,60,0,0 +67929,-100,4,2,1,5,0,0 +67985,-100,4,2,1,60,0,0 +68040,-100,4,2,1,5,0,0 +68096,-100,4,2,1,60,0,0 +68151,-100,4,2,1,5,0,0 +68207,-100,4,2,1,60,0,0 +68262,-100,4,2,1,5,0,0 +68318,-100,4,2,1,60,0,0 +68874,-83.3333333333333,4,2,1,60,0,0 +69096,-100,4,2,1,60,0,0 +69097,-100,4,2,1,5,0,0 +69207,-100,4,2,1,60,0,0 +69263,-100,4,2,1,5,0,0 +69319,-100,4,2,1,60,0,0 +69374,-100,4,2,1,5,0,0 +69430,-100,4,2,1,60,0,0 +69486,-100,4,2,1,5,0,0 +69542,-100,4,2,1,60,0,0 +69597,-100,4,2,1,5,0,0 +69651,-100,4,2,1,60,0,0 +69707,-100,4,2,1,5,0,0 +69762,-100,4,2,1,60,0,0 +69818,-100,4,2,1,5,0,0 +69874,-100,4,2,1,60,0,0 +69929,-100,4,2,1,5,0,0 +69985,-100,4,2,1,60,0,0 +70040,-100,4,2,1,60,0,0 +70041,-100,4,2,1,5,0,0 +70096,-100,4,2,1,60,0,0 +70818,-100,4,2,1,5,0,0 +70873,-100,4,2,1,60,0,0 +71207,-71.4285714285714,4,2,1,60,0,0 +71429,-100,4,2,1,60,0,0 +71874,-71.4285714285714,4,2,1,60,0,0 +72041,-71.4285714285714,4,2,1,5,0,0 +72096,-133.333333333333,4,2,1,60,0,0 +72263,-133.333333333333,4,2,1,5,0,0 +72318,-71.4285714285714,4,2,1,60,0,0 +72485,-71.4285714285714,4,2,1,5,0,0 +72540,-133.333333333333,4,2,1,60,0,0 +72985,-66.6666666666667,4,2,1,60,0,0 +73207,-100,4,2,1,60,0,0 +73651,-133.333333333333,4,2,1,45,0,0 +75318,-133.333333333333,4,2,1,5,0,0 +75429,-133.333333333333,4,2,1,45,0,0 +76762,-100,4,2,1,45,0,0 +77096,-100,4,2,1,5,0,0 +77207,-100,4,2,1,70,0,1 +77818,-100,4,2,1,5,0,1 +77874,-100,4,2,1,70,0,1 +78262,-100,4,2,1,5,0,1 +78318,-100,4,2,1,70,0,1 +78540,-83.3333333333333,4,2,1,70,0,1 +78985,-100,4,2,1,70,0,1 +79596,-100,4,2,1,5,0,1 +79651,-100,4,2,1,70,0,1 +80040,-100,4,2,1,5,0,1 +80096,-100,4,2,1,70,0,1 +80318,-83.3333333333333,4,2,1,70,0,1 +84318,-100,4,2,1,70,0,1 +84929,-100,4,2,1,5,0,1 +84985,-100,4,2,1,70,0,1 +85207,-100,4,2,1,70,0,1 +85374,-100,4,2,1,5,0,1 +85429,-100,4,2,1,70,0,1 +85651,-83.3333333333333,4,2,1,70,0,1 +86096,-100,4,2,1,70,0,1 +86707,-100,4,2,1,5,0,1 +86762,-100,4,2,1,70,0,1 +88818,-100,4,2,1,5,0,1 +88874,-100,4,2,1,70,0,1 +88929,-100,4,2,1,5,0,1 +88985,-100,4,2,1,70,0,1 +89040,-100,4,2,1,5,0,1 +89096,-100,4,2,1,70,0,1 +92040,-100,4,2,1,5,0,1 +92096,-100,4,2,1,70,0,1 +92485,-100,4,2,1,5,0,1 +92540,-100,4,2,1,70,0,1 +97651,-200,4,2,1,70,0,1 +97818,-200,4,2,1,5,0,1 +97874,-66.6666666666667,4,2,1,70,0,1 +97985,-66.6666666666667,4,2,1,70,0,1 +98040,-66.6666666666667,4,2,1,5,0,1 +98096,-133.333333333333,4,2,1,70,0,1 +98262,-133.333333333333,4,2,1,5,0,1 +98318,-66.6666666666667,4,2,1,70,0,1 +98540,-100,4,2,1,70,0,1 +99151,-100,4,2,1,5,0,1 +99207,-100,4,2,1,70,0,1 +99596,-100,4,2,1,5,0,1 +99651,-100,4,2,1,70,0,1 +103040,-100,4,2,1,5,0,1 +103096,-100,4,2,1,70,0,1 +103151,-100,4,2,1,5,0,1 +103207,-100,4,2,1,70,0,1 +103262,-100,4,2,1,5,0,1 +103318,-100,4,2,1,70,0,1 +105207,-83.3333333333333,4,2,1,70,0,1 +105540,-83.3333333333333,4,2,1,70,0,1 +105651,-133.333333333333,4,2,1,60,0,0 +105985,-133.333333333333,4,2,1,5,0,0 +106096,-200,4,2,1,60,0,0 +106985,-66.6666666666667,4,2,1,60,0,0 +107151,-66.6666666666667,4,2,1,5,0,0 +107207,-66.6666666666667,4,2,1,60,0,0 +107429,-100,4,2,1,60,0,0 +107763,-100,4,2,1,5,0,0 +107874,-100,4,2,1,60,0,0 +108874,-100,4,2,1,60,0,0 +109374,-100,4,2,1,5,0,0 +109429,-100,4,2,1,60,0,0 +109596,-100,4,2,1,5,0,0 +109651,-200,4,2,1,60,0,0 +109929,-100,4,2,1,60,0,0 +109984,-100,4,2,1,60,0,0 +110262,-100,4,2,1,5,0,0 +110318,-100,4,2,1,60,0,0 +110485,-100,4,2,1,5,0,0 +110540,-100,4,2,1,60,0,0 +110707,-100,4,2,1,5,0,0 +110762,-66.6666666666667,4,2,1,60,0,0 +110929,-100,4,2,1,60,0,0 +110985,-133.333333333333,4,2,1,60,0,0 +111429,-133.333333333333,4,2,1,60,0,0 +111596,-133.333333333333,4,2,1,60,0,0 +111651,-133.333333333333,4,2,1,60,0,0 +111818,-133.333333333333,4,2,1,60,0,0 +111874,-100,4,2,1,60,0,1 +112318,-83.3333333333333,4,2,1,60,0,1 +112429,-100,4,2,1,5,0,0 + + +[Colours] +Combo1 : 112,75,180 +Combo2 : 0,255,255 +Combo3 : 255,15,117 +Combo4 : 255,135,15 + +[HitObjects] +309,230,763,37,0,3:0:0:0: +485,146,985,2,0,L|406:167,1,67.4999968671799,8|0,3:0|0:0,0:0:0:0: +374,249,1207,2,0,L|299:227,1,67.4999968671799,8|0,3:0|0:0,0:0:0:0: +196,91,1429,2,0,L|191:44,3,33.7499984335899,0|0|0|0,3:0|3:0|3:0|3:0,0:0:0:0: +124,173,1651,2,0,L|131:222,2,44.9999979114532,0|0|0,3:0|3:0|3:0,0:0:0:0: +221,284,1874,2,0,L|213:208,1,67.4999968671799,0|0,3:0|3:0,0:0:0:0: +292,86,2096,38,0,L|310:234,1,146.249990980625,12|0,3:0|0:0,0:0:0:0: +314,328,2540,38,0,B|280:359|280:359|230:320|252:242|313:230,1,209.999990253448,0|0,3:0|0:0,0:0:0:0: +421,300,2874,1,0,0:0:0:0: +421,300,2985,2,0,P|461:288|491:253,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +309,231,3207,2,0,P|297:190|305:153,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +394,22,3429,5,0,3:0:0:0: +461,72,3540,2,0,B|477:103|477:103|461:148,1,74.999998807907,0|4,0:0|0:0,0:0:0:0: +378,183,3762,2,0,L|206:157,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +229,161,4096,2,0,P|227:202|211:250,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +61,384,4318,38,0,P|101:359|134:322,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +317,310,4540,2,0,P|267:305|226:288,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +141,110,4762,2,0,B|121:175|152:226|152:226|152:202|161:183,1,149.999997615814,8|4,3:0|0:0,0:0:0:0: +155,196,5096,6,0,P|67:211|79:286,1,179.999991645813,0|0,0:0|0:0,0:0:0:0: +212,366,5429,38,0,P|207:335|174:281,1,56.2500012516975,4|0,0:0|0:0,0:0:0:0: +206,286,5651,2,0,P|236:297|299:295,1,56.2500012516975,8|0,3:0|0:0,0:0:0:0: +281,321,5874,2,0,P|257:340|227:396,1,56.2500012516975,4|0,0:0|0:0,0:0:0:0: +124,246,6096,6,0,P|198:198|277:232,1,149.999997615814,0|0,3:0|0:0,0:0:0:0: +253,211,6429,1,0,0:0:0:0: +276,99,6540,2,0,P|335:139|369:215,1,149.999997615814,8|4,3:0|0:0,0:0:0:0: +368,208,6874,1,0,0:0:0:0: +430,96,6985,37,0,3:0:0:0: +497,147,7096,2,0,P|507:189|488:244,1,74.999998807907,0|4,0:0|0:0,0:0:0:0: +414,379,7318,2,0,B|383:322|421:267|421:267|421:308,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +421,298,7651,2,0,P|378:312|336:304,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +270,170,7874,6,0,P|275:228|236:278,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +94,300,8096,2,0,P|133:263|208:274,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +261,374,8318,2,0,L|176:365,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +38,377,8540,2,0,L|55:197,1,168.750003755093,4|0,0:0|0:0,0:0:0:0: +123,25,8985,38,0,L|132:110,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +217,242,9207,2,0,L|237:168,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +48,92,9429,5,4,0:0:0:0: +63,176,9540,1,0,0:0:0:0: +83,259,9651,38,0,P|167:223|231:255,1,149.999997615814,0|0,3:0|0:0,0:0:0:0: +274,312,9985,1,0,0:0:0:0: +274,312,10096,2,0,L|354:292,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +459,225,10318,2,0,L|375:204,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +269,107,10540,1,0,3:0:0:0: +276,54,10651,1,0,0:0:0:0: +313,17,10762,1,4,0:0:0:0: +363,9,10874,1,0,0:0:0:0: +363,9,11096,5,0,0:0:0:0: +432,68,11207,2,0,P|444:107|425:154,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +309,252,11429,38,0,P|297:195|321:158,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +450,316,11651,2,0,L|361:312,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +160,341,11874,2,0,B|187:380|187:380|233:309|177:235,1,187.499997019767,8|4,3:0|0:0,0:0:0:0: +116,200,12207,6,0,P|52:224|122:264,1,168.750003755093,0|4,0:0|0:0,0:0:0:0: +297,91,12762,37,8,3:0:0:0: +276,44,12874,1,0,0:0:0:0: +226,27,12985,1,4,0:0:0:0: +187,63,13096,1,0,0:0:0:0: +196,115,13207,1,0,0:0:0:0: +376,144,13429,2,0,L|378:121,2,16.6666664017571,0|0|0,0:0|0:0|0:0,0:0:0:0: +436,220,13651,6,0,B|395:211|373:164|373:164|332:208|264:185,1,179.999991645813,8|4,3:0|0:0,0:0:0:0: +276,44,13985,1,0,0:0:0:0: +196,115,14096,38,0,L|139:124,4,37.4999994039535,0|0|0|0|4,3:0|0:0|0:0|0:0|0:0,0:0:0:0: +82,69,14429,1,0,0:0:0:0: +106,190,14540,2,0,L|126:276,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +218,383,14762,2,0,L|234:309,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +26,231,14985,5,0,3:0:0:0: +253,202,15207,37,0,0:0:0:0: +331,271,15318,1,0,0:0:0:0: +233,309,15429,1,8,3:0:0:0: +389,73,15651,6,0,P|410:22|447:112,1,224.999996423721,4|0,3:0|0:0,0:0:0:0: +391,165,16096,1,0,0:0:0:0: +377,177,16207,1,0,0:0:0:0: +365,187,16318,38,0,B|253:261|221:119|94:192,1,269.999987468719,0|0,0:0|0:0,0:0:0:0: +73,319,16762,22,0,P|133:336|116:236,1,168.750003755093,4|0,3:0|0:0,0:0:0:0: +139,258,17207,6,0,P|138:315|69:283,1,112.500002503395,8|0,3:0|0:0,0:0:0:0: +92,323,17762,37,0,0:0:0:0: +43,245,17874,1,4,0:0:0:0: +4,322,17985,1,0,0:0:0:0: +133,245,18096,1,0,3:0:0:0: +29,105,18318,6,0,L|38:40,3,56.2500012516975,4|0|0|0,3:0|0:0|0:0|0:0,0:0:0:0: +50,30,18540,38,0,P|111:56|193:25,1,149.999997615814,0|0,3:0|0:0,0:0:0:0: +240,120,18985,2,0,P|328:91|394:125,1,149.999997615814,8|4,3:0|0:0,0:0:0:0: +409,213,19318,2,0,B|377:226|377:226|243:200,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +119,187,19651,2,0,L|127:286,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +179,338,19874,1,8,3:0:0:0: +45,307,19985,6,0,L|3:297,2,37.4999994039535,0|0|4,0:0|0:0|0:0,0:0:0:0: +103,380,20207,1,0,3:0:0:0: +212,257,20318,38,0,P|233:218|231:171,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +111,118,20540,1,4,0:0:0:0: +111,118,20762,6,0,L|197:109,1,74.999998807907,8|4,3:0|0:0,0:0:0:0: +256,18,21096,37,0,0:0:0:0: +337,121,21207,2,0,P|350:60|403:16,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +384,26,21429,2,0,P|406:86|465:122,1,112.49999821186,0|0,0:0|0:0,0:0:0:0: +443,114,21651,2,0,P|377:105|327:131,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +352,223,21874,6,0,B|369:230|369:230|391:228|391:228|416:239|416:239|440:235|440:235|462:244|462:244|489:249,1,112.500002503395,4|0,0:0|0:0,0:0:0:0: +322,343,22096,37,0,3:0:0:0: +259,270,22207,2,0,P|223:276|182:263,2,74.999998807907,0|4|0,0:0|0:0|0:0,0:0:0:0: +86,360,22540,5,8,3:0:0:0: +15,295,22651,2,0,L|0:201,2,74.999998807907,0|4|0,0:0|0:0|0:0,0:0:0:0: +94,384,22985,38,0,P|118:328|112:277,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +0,211,23429,22,0,L|76:196,1,74.999998807907,12|0,3:0|0:0,0:0:0:0: +215,134,23651,2,0,L|114:110,1,74.999998807907,12|0,3:0|0:0,0:0:0:0: +33,124,23874,22,0,L|43:2,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +150,269,24318,2,0,L|162:194,1,74.999998807907,0|4,3:0|0:0,0:0:0:0: +229,134,24651,6,0,L|386:164,1,112.500002503395,12|0,3:0|0:0,0:0:0:0: +486,268,24874,37,0,0:0:0:0: +410,119,24985,1,4,0:0:0:0: +381,213,25096,1,0,0:0:0:0: +512,120,25207,1,0,3:0:0:0: +247,36,25429,6,0,L|191:25,3,37.4999994039535,4|0|0|0,3:0|0:0|0:0|0:0,0:0:0:0: +185,24,25651,2,0,B|145:72|145:72|174:164,1,149.999997615814,0|0,3:0|0:0,0:0:0:0: +253,219,26096,2,0,B|281:311|281:311|228:382,1,149.999997615814,8|4,3:0|0:0,0:0:0:0: +100,363,26429,38,0,L|259:354,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +404,262,26762,1,4,0:0:0:0: +390,352,26874,1,0,0:0:0:0: +314,295,26985,1,8,3:0:0:0: +425,256,27096,6,0,L|492:246,2,37.4999994039535,0|0|4,0:0|0:0|0:0,0:0:0:0: +329,216,27318,1,0,3:0:0:0: +193,177,27429,38,0,L|266:161,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +322,107,27651,1,4,0:0:0:0: +322,107,27874,2,0,L|310:238,1,112.500002503395,8|4,3:0|0:0,0:0:0:0: +110,299,28207,5,0,0:0:0:0: +164,231,28318,2,0,B|168:303|168:303|121:338,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +30,284,28540,2,0,B|90:244|90:244|144:267,1,112.49999821186,0|0,0:0|0:0,0:0:0:0: +148,371,28762,2,0,B|83:338|83:338|76:280,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +194,201,28985,38,0,B|207:210|207:210|227:210|227:210|243:217|243:217|265:218|265:218|282:227|282:227|305:225|305:225|325:238,1,112.500002503395,4|0,0:0|0:0,0:0:0:0: +492,114,29207,6,0,P|445:136|410:138,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +324,102,29429,2,0,P|291:68|280:29,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +418,17,29651,1,8,3:0:0:0: +495,201,29874,1,4,3:2:0:0: +221,136,30096,37,0,3:0:0:0: +299,188,30207,2,0,B|316:251|316:251|271:352,1,149.999997615814,4|0,3:0|0:0,0:0:0:0: +115,334,30540,6,0,P|11:252|167:266,1,382.500001215934,0|0,0:0|0:0,0:0:0:0: +216,326,30985,38,0,L|304:331,1,63.7500002026557,4|0,3:0|0:0,0:0:0:0: +280,330,31429,6,0,L|293:241,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +426,252,31651,2,0,L|439:163,1,74.999998807907,0|0,0:0|0:0,0:0:0:0: +253,158,31874,37,0,3:0:0:0: +258,132,31985,1,0,0:0:0:0: +337,111,32096,5,4,0:0:0:0: +341,85,32207,1,0,0:0:0:0: +271,30,32318,38,0,B|212:42|212:42|141:19,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +163,26,32540,2,0,L|144:181,1,149.999997615814,4|0,0:0|3:0,0:0:0:0: +445,343,32985,22,0,B|439:234|439:234|384:269,1,149.999997615814,4|8,0:0|3:0,0:0:0:0: +240,257,33429,2,0,B|263:148|263:148|291:205,1,149.999997615814,4|0,0:0|3:0,0:0:0:0: +68,333,33874,2,0,B|83:233|83:233|41:256,1,149.999997615814,4|8,0:0|3:0,0:0:0:0: +344,347,34318,22,0,B|368:372|368:372|455:355|455:355|472:308,1,149.999997615814,4|0,0:0|0:0,0:0:0:0: +452,255,34540,2,0,B|389:212|389:212|332:273,1,149.999997615814,0|4,3:0|0:0,0:0:0:0: +256,220,34874,5,0,0:0:0:0: +256,220,34985,2,0,B|256:128,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +256,70,35207,2,0,B|256:162,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +112,312,35429,37,0,3:0:0:0: +60,255,35540,2,0,B|123:212|123:212|180:273,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +169,350,35874,6,0,B|144:375|144:375|57:358|57:358|40:311,1,149.999997615814,8|0,3:0|3:0,0:0:0:0: +62,169,36429,6,0,L|76:267,2,74.999998807907,0|4|0,0:0|0:0|0:0,0:0:0:0: +134,61,36762,1,8,3:0:0:0: +201,113,36874,2,0,L|215:211,2,74.999998807907,0|4|0,0:0|0:0|0:0,0:0:0:0: +298,272,37207,6,0,L|315:184,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +330,114,37429,1,4,0:0:0:0: +446,176,37540,2,0,B|404:214|404:214|307:197,1,149.999997615814,4|0,3:0|0:0,0:0:0:0: +231,240,37874,2,0,P|223:199|231:162,1,74.999998807907,4|0,3:0|0:0,0:0:0:0: +325,285,38096,6,0,L|154:300,1,149.999997615814,4|0,3:0|0:0,0:0:0:0: +175,298,38540,6,0,L|163:396,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +75,208,38762,2,0,L|63:306,1,74.999998807907,0|0,0:0|0:0,0:0:0:0: +233,74,38985,37,0,3:0:0:0: +231,98,39096,1,0,0:0:0:0: +156,139,39207,5,4,0:0:0:0: +155,165,39318,1,0,0:0:0:0: +227,215,39429,38,0,P|282:209|352:230,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +336,222,39651,2,0,L|366:67,1,149.999997615814,4|0,0:0|3:0,0:0:0:0: +81,35,40096,22,0,B|82:105|82:105|118:136|118:136|132:89,1,149.999997615814,4|8,0:0|3:0,0:0:0:0: +272,158,40540,2,0,B|270:228|270:228|234:259|234:259|220:212,1,149.999997615814,4|0,0:0|3:0,0:0:0:0: +423,36,40985,2,0,B|400:102|400:102|423:143|423:143|453:104,1,149.999997615814,4|8,0:0|3:0,0:0:0:0: +512,278,41429,6,0,P|415:258|361:293,1,149.999997615814,4|0,0:0|0:0,0:0:0:0: +359,302,41651,6,0,B|320:264|320:264|310:187,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +322,190,41874,2,0,L|449:171,1,112.49999821186,4|0,0:0|0:0,0:0:0:0: +443,159,42096,1,8,3:0:0:0: +240,52,42318,6,0,B|255:79|255:79|241:135,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +177,166,42540,1,0,3:0:0:0: +163,151,42651,2,0,B|161:207|161:207|192:240|192:240|189:299,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +131,365,42985,2,0,P|198:322|280:345,1,149.999997615814,8|4,3:0|0:0,0:0:0:0: +335,377,43318,1,0,0:0:0:0: +442,239,43429,38,0,B|456:178|456:178|422:136|422:136|427:68|427:68|449:112,1,224.999996423721,0|0,3:0|0:0,0:0:0:0: +444,103,43874,2,0,P|402:118|356:120,1,74.999998807907,0|0,0:0|0:0,0:0:0:0: +249,28,44096,2,0,P|295:35|324:48,1,74.999998807907,0|0,0:0|0:0,0:0:0:0: +364,201,44318,5,0,0:0:0:0: +332,195,44429,1,0,0:0:0:0: +251,135,44540,37,0,0:0:0:0: +281,123,44651,1,0,0:0:0:0: +332,195,44762,6,0,B|356:269|324:333|324:333|303:293,1,179.999991645813,4|0,0:3|0:0,0:0:0:0: +61,25,45207,38,0,L|88:158,1,112.500002503395,0|0,3:0|0:0,0:0:0:0: +84,136,45651,1,8,3:0:0:0: +84,136,46096,1,0,3:0:0:0: +176,33,46207,2,0,L|164:103,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +219,207,46429,2,0,L|232:152,1,56.2500012516975,0|8,0:0|3:0,0:0:0:0: +312,65,46651,1,0,0:0:0:0: +312,65,46762,2,0,L|398:94,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +512,176,46985,5,0,3:0:0:0: +421,192,47096,1,0,0:0:0:0: +421,192,47429,1,8,3:0:0:0: +402,357,47651,37,0,0:0:0:0: +394,277,47762,1,0,0:0:0:0: +328,324,47874,1,0,3:0:0:0: +328,324,48318,1,8,3:0:0:0: +110,357,48540,5,0,0:0:0:0: +118,277,48651,1,0,0:0:0:0: +184,324,48763,1,0,3:0:0:0: +110,357,48874,1,0,0:0:0:0: +110,357,49207,1,8,3:0:0:0: +110,357,49651,1,0,3:0:0:0: +0,283,49762,38,0,P|41:301|97:295,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +188,219,49985,2,0,P|168:236|137:246,1,56.2500012516975,0|8,0:0|3:0,0:0:0:0: +49,137,50207,1,0,0:0:0:0: +49,137,50318,2,0,P|65:184|93:205,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +107,67,50540,5,0,3:0:0:0: +32,15,50651,1,0,0:0:0:0: +32,15,50985,1,8,3:0:0:0: +265,114,51207,37,0,0:0:0:0: +254,196,51318,1,0,0:0:0:0: +241,279,51429,1,0,3:0:0:0: +241,279,51651,1,0,0:0:0:0: +336,207,51762,6,0,P|397:191|371:274,1,168.750003755093,0|0,0:0|0:0,0:0:0:0: +83,206,52318,5,0,3:0:0:0: +83,206,52429,2,0,L|101:260,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +40,383,52651,2,0,P|70:355|90:324,1,56.2500012516975,0|8,0:0|3:0,0:0:0:0: +214,334,52874,1,0,0:0:0:0: +214,334,52985,2,0,P|171:322|140:304,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +151,160,53207,5,0,3:0:0:0: +188,135,53318,1,0,0:0:0:0: +232,129,53429,1,0,0:0:0:0: +273,146,53540,1,0,0:0:0:0: +339,198,53651,37,8,3:0:0:0: +383,199,53762,1,0,0:0:0:0: +426,185,53874,1,0,0:0:0:0: +450,147,53985,1,0,0:0:0:0: +444,61,54096,6,0,P|414:28|377:15,1,56.2500012516975,0|0,3:0|0:0,0:0:0:0: +301,28,54318,2,0,P|268:48|255:77,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +189,271,54540,38,0,P|209:222|204:198,1,56.2500012516975,8|0,3:0|0:0,0:0:0:0: +186,114,54762,2,0,P|152:74|124:68,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +27,137,54985,5,0,3:0:0:0: +34,167,55096,1,0,0:0:0:0: +122,204,55207,37,0,0:0:0:0: +116,178,55318,1,0,0:0:0:0: +48,249,55429,5,8,3:0:0:0: +54,274,55540,1,0,0:0:0:0: +124,329,55651,38,0,P|157:326|200:310,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +320,185,55874,5,0,3:0:0:0: +287,175,55985,1,0,0:0:0:0: +254,181,56096,2,0,P|258:221|264:241,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +337,347,56318,2,0,P|348:321|350:293,1,56.2500012516975,8|0,3:0|0:0,0:0:0:0: +418,197,56540,37,0,0:0:0:0: +418,197,56651,2,0,L|492:180,1,56.2500012516975,0|0,0:0|3:0,0:0:0:0: +329,114,56874,2,0,L|262:94,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +436,59,57096,6,0,L|413:126,1,56.2500012516975,0|8,0:0|3:0,0:0:0:0: +332,194,57318,2,0,L|353:259,2,56.2500012516975,0|0|0,0:0|0:0|0:0,0:0:0:0: +202,194,57651,37,0,3:0:0:0: +224,233,57762,1,0,0:0:0:0: +222,279,57874,1,0,0:0:0:0: +193,314,57985,1,0,0:0:0:0: +144,244,58096,5,0,0:0:0:0: +127,214,58207,1,0,0:0:0:0: +126,180,58318,1,0,0:0:0:0: +139,149,58429,1,0,0:0:0:0: +224,113,58540,38,0,B|262:88|235:70|189:83|189:83|224:138|194:193,1,194.999987974167 +299,319,58874,1,0,0:0:0:0: +299,319,58985,2,0,B|316:283|314:237|314:237|278:226|278:226|320:243|359:227,1,202.49999060154,4|0,0:0|0:0,0:0:0:0: +428,181,59429,22,0,P|454:129|399:4,1,179.999991645813,0|0,3:0|0:0,0:0:0:0: +418,18,59874,2,0,L|373:15,6,24.9999996026357,8|0|0|0|0|0|4,3:0|0:0|0:0|0:0|0:0|0:0|0:0,0:0:0:0: +428,181,60207,5,0,0:0:0:0: +352,209,60318,1,0,3:0:0:0: +278,177,60429,1,0,0:0:0:0: +208,225,60540,2,0,L|222:267,2,37.4999994039535,4|0|0,0:0|0:0|0:0,0:0:0:0: +71,144,60762,38,0,L|65:109,3,24.9999996026357,8|0|0|0,3:0|0:0|0:0|0:0,0:0:0:0: +145,86,60985,1,4,0:0:0:0: +163,127,61096,1,0,0:0:0:0: +161,171,61207,1,0,3:0:0:0: +136,208,61318,1,0,0:0:0:0: +99,231,61429,2,0,B|91:279|91:279|117:314,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +177,378,61651,5,8,3:0:0:0: +177,378,61762,2,0,B|231:371|231:371|272:326|272:326|345:319,1,179.999991645813 +417,293,62096,1,0,3:0:0:0: +438,263,62207,1,0,0:0:0:0: +436,225,62318,1,4,0:0:0:0: +412,196,62429,1,0,0:0:0:0: +320,172,62540,6,0,P|307:192|291:204,1,37.4999994039535,8|0,3:0|0:0,0:0:0:0: +291,147,62651,2,0,P|274:156|245:153,1,37.4999994039535,0|0,0:0|0:0,0:0:0:0: +276,114,62762,2,0,P|250:107|234:94,1,37.4999994039535,4|0,0:0|0:0,0:0:0:0: +283,81,62874,2,0,P|265:61|260:45,1,37.4999994039535,0|0,0:0|0:0,0:0:0:0: +365,31,62985,38,0,P|398:44|442:49,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +512,169,63207,2,0,P|466:163|421:176,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +350,107,63429,1,8,3:0:0:0: +293,237,63540,38,0,L|276:158,1,74.999998807907,0|0,0:0|0:0,0:0:0:0: +428,269,63762,2,0,B|373:275|373:275|338:249|338:249|267:255,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +191,318,64096,2,0,B|182:355|182:355|212:395,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +192,186,64318,5,8,3:0:0:0: +135,253,64429,2,0,L|56:270,2,74.999998807907,0|4|0,0:0|0:0|0:0,0:0:0:0: +24,136,64762,38,0,P|69:76|158:75,1,157.499992690086,0|0,3:0|0:0,0:0:0:0: +160,80,64985,6,0,P|193:102|255:102,1,84.3750018775463,4|0,0:0|0:0,0:0:0:0: +276,34,65207,38,0,L|290:212,1,157.499992690086,8|0,3:0|0:0,0:0:0:0: +291,219,65429,6,0,L|311:132,1,84.3750018775463,4|0,0:0|0:0,0:0:0:0: +381,111,65651,38,0,B|418:126|418:126|460:126,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +221,163,65874,2,0,B|186:143|186:143|139:153,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +41,231,66096,1,8,3:0:0:0: +49,267,66207,1,0,0:0:0:0: +56,303,66318,1,4,0:0:0:0: +67,288,66429,1,4,0:0:0:0: +77,270,66540,6,0,P|171:255|72:350,1,337.500007510185,0|0,0:0|0:0,0:0:0:0: +95,356,66985,38,0,L|185:343,1,74.999998807907,8|4,3:0|0:0,0:0:0:0: +274,286,67318,6,0,B|289:324|289:324|268:378,1,74.999998807907,0|0,0:0|3:0,0:0:0:0: +191,227,67540,1,0,0:0:0:0: +255,168,67651,2,0,L|264:116,2,37.4999994039535,4|0|0,0:0|0:0|0:0,0:0:0:0: +147,83,67874,2,0,L|154:108,3,24.9999996026357,8|0|0|0,3:0|0:0|0:0|0:0,0:0:0:0: +80,148,68096,38,0,L|98:224,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +125,356,68318,1,0,3:0:0:0: +0,319,68429,1,0,0:0:0:0: +0,319,68540,2,0,L|76:294,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +277,219,68762,5,8,3:0:0:0: +277,219,68874,2,0,B|327:199|327:199|293:138|197:173,1,179.999991645813,0|0,0:0|0:0,0:0:0:0: +157,273,69207,37,0,3:0:0:0: +175,316,69318,1,0,0:0:0:0: +212,334,69429,1,4,0:0:0:0: +254,333,69540,1,0,0:0:0:0: +332,268,69651,38,0,P|333:237|343:213,1,37.4999994039535,8|0,3:0|0:0,0:0:0:0: +373,265,69762,2,0,P|386:239|404:232,1,37.4999994039535,0|0,0:0|0:0,0:0:0:0: +413,284,69874,2,0,P|430:269|454:269,1,37.4999994039535,4|0,0:0|0:0,0:0:0:0: +433,318,69985,2,0,P|452:320|474:337,1,37.4999994039535,4|0,0:0|0:0,0:0:0:0: +401,384,70096,6,0,P|353:378|319:346,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +251,251,70318,2,0,P|240:196|260:154,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +401,18,70540,1,8,3:0:0:0: +401,18,70651,2,0,P|409:54|398:90,1,74.999998807907,0|0,0:0|0:0,0:0:0:0: +327,193,70874,2,0,L|304:45,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +290,26,71207,6,0,L|308:144,1,104.999995126724,0|0,0:0|0:0,0:0:0:0: +272,302,71429,2,0,L|187:288,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +33,217,71651,37,4,0:0:0:0: +27,187,71762,1,4,0:0:0:0: +20,157,71874,2,0,B|103:140|103:140|162:58,1,157.499992690086,0|0,3:0|0:0,0:0:0:0: +145,82,72096,6,0,L|218:75,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +336,136,72318,38,0,P|331:213|231:208,1,157.499992690086 +263,232,72540,6,0,L|278:300,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +183,384,72762,2,0,L|172:307,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +37,140,72985,38,0,B|10:168|10:168|17:204|17:204|54:220|54:220|89:196|89:196|87:157|87:157|57:138,1,225.00000500679 +275,372,73651,6,0,P|320:352|387:369,1,112.500002503395 +380,364,74096,2,0,L|436:358,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +495,271,74318,2,0,L|424:282,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +339,270,74540,1,0,0:0:0:0: +339,270,74651,1,0,0:0:0:0: +339,270,74762,2,0,L|329:196,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +408,46,74985,38,0,L|392:120,1,56.2500012516975 +220,230,75207,2,0,L|209:156,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +282,7,75429,37,0,0:0:0:0: +300,98,75540,1,0,0:0:0:0: +197,25,75651,5,0,0:0:0:0: +222,103,75762,1,0,0:0:0:0: +126,69,75874,5,0,0:0:0:0: +153,134,75985,1,0,0:0:0:0: +76,145,76096,5,0,0:0:0:0: +116,179,76207,1,0,0:0:0:0: +70,222,76318,5,0,0:0:0:0: +111,222,76429,1,0,0:0:0:0: +134,253,76540,6,0,P|135:298|126:314,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +21,384,76762,2,0,P|124:354|260:391,1,224.999996423721,0|0,0:0|0:0,0:0:0:0: +384,366,77207,22,0,L|394:268,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +499,62,77429,2,0,L|486:135,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +507,237,77651,2,0,P|450:231|388:184,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +404,203,77874,2,0,L|313:217,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +113,212,78096,6,0,P|128:267|111:328,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +115,319,78318,2,0,L|213:340,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +274,371,78540,38,0,L|257:186,1,179.999991645813,8|0,3:0|0:0,0:0:0:0: +128,139,78874,1,0,0:0:0:0: +128,139,78985,6,0,L|230:128,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +365,34,79207,37,4,0:0:0:0: +430,114,79318,1,0,0:0:0:0: +361,184,79429,2,0,P|304:170|277:110,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +278,126,79651,2,0,L|189:133,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +64,263,79874,6,0,B|37:230|37:230|50:143,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +66,119,80096,2,0,L|80:210,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +71,361,80318,38,0,B|135:350|135:350|182:305|182:305|243:297,1,179.999991645813,8|0,3:0|0:0,0:0:0:0: +302,247,80651,1,0,0:0:0:0: +222,211,80762,1,0,3:0:0:0: +478,344,80985,5,4,0:0:0:0: +491,309,81096,5,0,0:0:0:0: +498,265,81207,5,8,3:0:0:0: +485,223,81318,5,0,0:0:0:0: +458,179,81429,5,4,0:0:0:0: +418,147,81540,5,0,0:0:0:0: +352,126,81651,5,0,3:0:0:0: +281,149,81762,5,0,0:0:0:0: +239,221,81874,5,4,0:0:0:0: +159,262,81985,5,0,0:0:0:0: +66,234,82096,5,8,3:0:0:0: +11,145,82207,5,0,0:0:0:0: +55,33,82318,5,4,0:0:0:0: +273,44,82540,37,0,3:0:0:0: +320,103,82651,1,0,0:0:0:0: +394,118,82762,1,4,0:0:0:0: +468,100,82874,1,0,0:0:0:0: +507,36,82985,1,8,3:0:0:0: +495,19,83207,5,4,0:0:0:0: +335,83,83318,1,0,0:0:0:0: +453,81,83429,1,0,3:0:0:0: +283,24,83540,2,0,P|196:37|141:120,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +60,238,83874,1,8,3:0:0:0: +21,164,83985,2,0,P|59:149|175:193,1,149.999997615814,0|8,0:0|3:0,0:0:0:0: +252,206,84318,38,0,P|271:160|264:125,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +139,257,84540,2,0,P|131:302|149:340,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +240,379,84762,2,0,B|330:360|330:360|298:344,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +312,351,84985,2,0,P|279:321|270:287,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +359,165,85207,6,0,B|389:202|389:202|368:282,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +373,265,85429,2,0,L|454:282,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +498,139,85651,38,0,P|446:120|396:0,1,179.999991645813,8|0,3:0|0:0,0:0:0:0: +394,13,85985,1,0,0:0:0:0: +301,92,86096,6,0,L|214:83,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +66,66,86318,1,4,0:0:0:0: +13,136,86429,1,0,0:0:0:0: +72,193,86540,2,0,P|120:210|190:178,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +176,192,86762,2,0,P|154:237|160:288,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +309,370,86985,37,0,3:0:0:0: +359,310,87096,1,0,0:0:0:0: +283,297,87207,2,0,L|203:318,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +4,203,87429,2,0,B|55:211|55:211|82:255|82:255|134:266,1,149.999997615814,8|0,3:0|0:0,0:0:0:0: +238,217,87762,1,0,0:0:0:0: +183,120,87874,6,0,L|89:111,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +98,33,88096,2,0,L|23:26,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +306,182,88318,38,0,L|400:173,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +391,95,88540,2,0,L|465:88,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +232,28,88762,2,0,L|220:92,1,37.4999994039535,0|0,3:0|0:0,0:0:0:0: +243,39,88874,2,0,L|231:103,1,37.4999994039535,0|0,0:0|0:0,0:0:0:0: +256,50,88985,2,0,L|251:87,1,37.4999994039535,4|0,0:0|0:0,0:0:0:0: +485,87,89207,6,0,L|493:51,3,37.4999994039535,8|0|0|0,3:0|0:0|0:0|0:0,0:0:0:0: +396,120,89429,2,0,L|411:197,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +471,317,89651,38,0,P|411:299|320:336,1,149.999997615814,0|4,3:0|0:0,0:0:0:0: +61,239,90096,2,0,P|121:221|212:258,1,149.999997615814,8|4,3:0|0:0,0:0:0:0: +367,21,90540,6,0,P|336:57|328:104,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +163,96,90762,2,0,P|194:132|202:179,1,74.999998807907,0|0,0:0|0:0,0:0:0:0: +190,346,90985,37,8,3:0:0:0: +328,272,91096,1,0,0:0:0:0: +154,272,91207,5,8,3:0:0:0: +365,338,91318,1,0,0:0:0:0: +257,382,91429,38,0,B|290:333|224:286|269:219,1,149.999997615814,4|4,3:0|0:0,0:0:0:0: +325,196,91762,1,0,0:0:0:0: +325,196,91874,2,0,P|365:210|436:184,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +430,190,92096,2,0,B|418:110,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +313,19,92318,2,0,L|190:36,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +201,34,92540,2,0,B|214:117,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +209,252,92762,5,8,3:0:0:0: +156,261,92874,1,0,0:0:0:0: +112,231,92985,1,4,0:0:0:0: +60,222,93096,1,0,0:0:0:0: +13,247,93207,38,0,P|4:288|19:328,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +173,186,93429,1,4,0:0:0:0: +215,120,93540,1,0,0:0:0:0: +162,49,93651,2,0,P|125:39|76:61,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +234,138,93874,2,0,P|273:157|313:148,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +385,39,94096,5,0,3:0:0:0: +337,286,94318,2,0,L|322:373,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +409,327,94540,2,0,P|418:277|280:230,1,224.999996423721,8|0,3:0|0:0,0:0:0:0: +239,319,94985,2,0,P|218:357|173:373,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +34,344,95207,37,4,0:0:0:0: +21,309,95318,5,0,0:0:0:0: +14,265,95429,5,8,3:0:0:0: +27,223,95540,5,0,0:0:0:0: +54,179,95651,5,4,0:0:0:0: +94,147,95762,5,0,0:0:0:0: +160,126,95873,5,0,3:0:0:0: +231,149,95984,5,0,0:0:0:0: +273,221,96096,5,4,0:0:0:0: +353,262,96207,5,0,0:0:0:0: +446,234,96318,5,8,3:0:0:0: +501,145,96429,5,0,0:0:0:0: +450,36,96540,5,4,0:0:0:0: +239,44,96762,5,0,3:0:0:0: +192,103,96873,1,0,0:0:0:0: +118,118,96984,1,4,0:0:0:0: +44,100,97096,1,0,0:0:0:0: +5,36,97207,1,8,3:0:0:0: +17,19,97429,37,4,0:0:0:0: +146,51,97540,1,0,0:0:0:0: +29,122,97651,2,0,L|39:193,1,56.2499991059302,0|0,3:0|0:0,0:0:0:0: +44,197,97874,6,0,P|100:231|176:201,1,112.500002503395,4|0,0:0|0:0,0:0:0:0: +301,160,98096,38,0,P|329:140|382:137,1,84.3750018775463,8|0,3:0|0:0,0:0:0:0: +398,147,98318,6,0,B|431:187|431:187|415:279,1,112.500002503395,4|0,0:0|0:0,0:0:0:0: +265,371,98540,38,0,L|180:361,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +127,202,98762,2,0,L|141:113,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +193,260,98985,2,0,P|144:291|68:278,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +91,290,99207,2,0,L|79:373,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +20,184,99429,6,0,B|4:141|4:141|27:66,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +23,78,99651,2,0,L|109:91,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +271,74,99874,2,0,P|254:31|222:12,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +186,180,100096,2,0,P|232:175|260:147,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +132,63,100318,37,0,3:0:0:0: +253,157,100540,1,4,0:0:0:0: +285,167,100651,1,0,0:0:0:0: +357,129,100762,5,8,3:0:0:0: +389,139,100873,1,0,0:0:0:0: +422,148,100985,2,0,P|407:200|416:233,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +459,377,101207,38,0,P|472:333|459:295,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +398,242,101429,2,0,L|314:257,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +165,354,101651,2,0,P|116:332|211:264,1,224.999996423721,8|0,3:0|0:0,0:0:0:0: +302,165,102096,6,0,L|292:89,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +392,91,102318,2,0,L|382:14,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +192,229,102540,38,0,L|212:136,1,74.999998807907,8|0,3:0|0:0,0:0:0:0: +107,172,102762,2,0,L|127:79,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +314,332,102985,6,0,L|305:278,1,37.4999994039535,0|0,3:0|0:0,0:0:0:0: +343,345,103096,2,0,L|334:291,1,37.4999994039535,0|0,0:0|0:0,0:0:0:0: +370,358,103207,2,0,L|361:304,1,37.4999994039535,4|0,0:0|0:0,0:0:0:0: +380,117,103429,38,0,L|374:75,3,37.4999994039535,8|0|0|0,3:0|0:0|0:0|0:0,0:0:0:0: +444,166,103651,2,0,P|417:188|346:191,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +392,2,103874,2,0,P|424:14|462:74,1,74.999998807907,4|0,3:0|0:0,0:0:0:0: +271,129,104096,2,0,P|265:94|298:31,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +505,113,104318,5,8,3:0:0:0: +269,217,104540,38,0,L|216:216,3,37.4999994039535,0|0|0|0,3:0|3:0|3:0|3:0,0:0:0:0: +360,220,104762,1,0,3:0:0:0: +296,384,104874,1,4,3:0:0:0: +102,307,105096,5,0,0:0:0:0: +102,307,105207,2,0,B|206:381|258:244|374:330,1,269.999987468719,12|0,3:0|0:0,0:0:0:0: +439,319,105651,6,0,P|379:336|396:236,1,168.750003755093,0|0,3:0|0:0,0:0:0:0: +373,258,106096,6,0,P|374:315|443:283,1,112.500002503395,8|0,3:0|0:0,0:0:0:0: +420,323,106651,37,0,0:0:0:0: +469,245,106763,1,4,0:0:0:0: +508,322,106874,1,0,0:0:0:0: +379,245,106985,1,8,3:0:0:0: +483,105,107207,6,0,L|474:40,3,56.2500012516975,4|0|0|0,3:0|0:0|0:0|0:0,0:0:0:0: +462,30,107429,38,0,P|401:56|319:25,1,149.999997615814,0|0,3:0|0:0,0:0:0:0: +272,120,107874,2,0,P|184:91|118:125,1,149.999997615814,8|4,3:0|0:0,0:0:0:0: +103,213,108207,2,0,B|128:232|128:232|269:200,1,149.999997615814,0|0,0:0|0:0,0:0:0:0: +393,187,108540,2,0,L|385:286,1,74.999998807907,4|0,0:0|0:0,0:0:0:0: +333,338,108763,1,8,3:0:0:0: +467,307,108874,6,0,L|509:297,2,37.4999994039535,0|0|4,0:0|0:0|0:0,0:0:0:0: +409,380,109096,1,0,0:0:0:0: +300,257,109207,38,0,P|279:218|281:171,1,74.999998807907,0|0,3:0|0:0,0:0:0:0: +401,118,109429,1,4,0:0:0:0: +401,118,109651,6,0,L|315:109,1,74.999998807907,8|4,3:0|0:0,0:0:0:0: +256,15,109985,37,0,0:0:0:0: +175,121,110096,2,0,P|162:60|109:16,1,112.49999821186,0|0,3:0|0:0,0:0:0:0: +128,26,110318,2,0,P|106:86|47:122,1,112.49999821186,0|0,0:0|0:0,0:0:0:0: +69,114,110540,2,0,P|135:105|185:131,1,112.49999821186,8|0,3:0|0:0,0:0:0:0: +160,223,110762,6,0,B|142:230|142:230|120:228|120:228|95:239|95:239|71:235|71:235|49:244|49:244|22:249,1,112.500002503395,4|0,0:0|0:0,0:0:0:0: +193,334,110985,38,0,P|216:310|242:301,1,56.2500012516975,0|0,3:0|0:0,0:0:0:0: +335,325,111207,2,0,P|366:353|378:379,1,56.2500012516975,0|0,0:0|0:0,0:0:0:0: +273,383,111429,2,0,L|304:213,1,168.750003755093,0|0,0:0|0:0,0:0:0:0: +383,255,111874,22,0,B|422:273|422:273|476:273,1,74.999998807907,8|0,3:0|3:0,0:0:0:0: +209,219,112096,2,0,B|169:221|169:221|131:206,1,74.999998807907,0|0,0:0|0:0,0:0:0:0: +403,147,112318,2,0,B|352:114|352:114|337:43|337:43|295:109|295:109|234:115,1,269.999987468719,8|0,3:0|0:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/2593923-expected-conversion.json b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/2593923-expected-conversion.json new file mode 100644 index 000000000000..4eb70e836b90 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/2593923-expected-conversion.json @@ -0,0 +1 @@ +{"Mappings":[{"StartTime":1433.0,"Objects":[{"StartTime":1433.0,"EndTime":1433.0,"X":493.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1595.0,"EndTime":1595.0,"X":482.0946,"Y":367.449646,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":1721.0,"EndTime":1721.0,"X":493.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":1919.0,"Objects":[{"StartTime":1919.0,"EndTime":1919.0,"X":442.0,"Y":244.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":2081.0,"EndTime":2081.0,"X":431.959137,"Y":180.4078,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":2207.0,"EndTime":2207.0,"X":442.0,"Y":244.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":2405.0,"Objects":[{"StartTime":2405.0,"EndTime":2405.0,"X":394.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":2531.0,"EndTime":2531.0,"X":335.6331,"Y":323.993469,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":2730.0,"Objects":[{"StartTime":2730.0,"EndTime":2730.0,"X":277.0,"Y":272.0}]},{"StartTime":2892.0,"Objects":[{"StartTime":2892.0,"EndTime":2892.0,"X":183.0,"Y":169.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":3018.0,"EndTime":3018.0,"X":194.8645,"Y":105.722687,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":3216.0,"Objects":[{"StartTime":3216.0,"EndTime":3216.0,"X":257.0,"Y":66.0}]},{"StartTime":3378.0,"Objects":[{"StartTime":3378.0,"EndTime":3378.0,"X":178.0,"Y":178.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":3666.0,"EndTime":3666.0,"X":63.2089653,"Y":223.380814,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":3865.0,"Objects":[{"StartTime":3865.0,"EndTime":3865.0,"X":196.0,"Y":303.0}]},{"StartTime":4027.0,"Objects":[{"StartTime":4027.0,"EndTime":4027.0,"X":53.0,"Y":214.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":4351.0,"EndTime":4351.0,"X":168.013245,"Y":216.502319,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":4513.0,"EndTime":4513.0,"X":188.218781,"Y":275.762878,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":4675.0,"EndTime":4675.0,"X":168.013245,"Y":216.502319,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":4963.0,"EndTime":4963.0,"X":53.0,"Y":214.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":5162.0,"Objects":[{"StartTime":5162.0,"EndTime":5162.0,"X":194.0,"Y":105.0}]},{"StartTime":5324.0,"Objects":[{"StartTime":5324.0,"EndTime":5324.0,"X":31.0,"Y":0.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":5612.0,"EndTime":5612.0,"X":133.993271,"Y":43.15782,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":5811.0,"Objects":[{"StartTime":5811.0,"EndTime":5811.0,"X":257.0,"Y":153.0}]},{"StartTime":5973.0,"Objects":[{"StartTime":5973.0,"EndTime":5973.0,"X":311.0,"Y":30.0}]},{"StartTime":6135.0,"Objects":[{"StartTime":6135.0,"EndTime":6135.0,"X":146.0,"Y":171.0}]},{"StartTime":6297.0,"Objects":[{"StartTime":6297.0,"EndTime":6297.0,"X":320.0,"Y":103.0}]},{"StartTime":6460.0,"Objects":[{"StartTime":6460.0,"EndTime":6460.0,"X":428.0,"Y":231.0}]},{"StartTime":6622.0,"Objects":[{"StartTime":6622.0,"EndTime":6622.0,"X":469.0,"Y":166.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":6946.0,"EndTime":6946.0,"X":351.395355,"Y":178.384933,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7072.0,"EndTime":7072.0,"X":326.584534,"Y":236.296982,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7270.0,"Objects":[{"StartTime":7270.0,"EndTime":7270.0,"X":234.0,"Y":379.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7396.0,"EndTime":7396.0,"X":295.6874,"Y":361.119446,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7595.0,"Objects":[{"StartTime":7595.0,"EndTime":7595.0,"X":155.0,"Y":291.0}]},{"StartTime":7757.0,"Objects":[{"StartTime":7757.0,"EndTime":7757.0,"X":326.88028,"Y":236.88028}]},{"StartTime":7838.0,"Objects":[{"StartTime":7838.0,"EndTime":7838.0,"X":330.440155,"Y":240.44014}]},{"StartTime":7919.0,"Objects":[{"StartTime":7919.0,"EndTime":7919.0,"X":334.0,"Y":244.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":8243.0,"EndTime":8243.0,"X":217.1122,"Y":283.230347,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":8369.0,"EndTime":8369.0,"X":154.385681,"Y":290.712372,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":8568.0,"Objects":[{"StartTime":8568.0,"EndTime":8568.0,"X":43.0,"Y":93.0}]},{"StartTime":8730.0,"Objects":[{"StartTime":8730.0,"EndTime":8730.0,"X":213.0,"Y":180.0}]},{"StartTime":8892.0,"Objects":[{"StartTime":8892.0,"EndTime":8892.0,"X":50.0,"Y":278.0}]},{"StartTime":9054.0,"Objects":[{"StartTime":9054.0,"EndTime":9054.0,"X":179.0,"Y":92.0}]},{"StartTime":9216.0,"Objects":[{"StartTime":9216.0,"EndTime":9216.0,"X":298.440155,"Y":318.440155}]},{"StartTime":9541.0,"Objects":[{"StartTime":9541.0,"EndTime":9541.0,"X":302.0,"Y":322.0}]},{"StartTime":9703.0,"Objects":[{"StartTime":9703.0,"EndTime":9703.0,"X":213.0,"Y":180.0}]},{"StartTime":9865.0,"Objects":[{"StartTime":9865.0,"EndTime":9865.0,"X":156.0,"Y":290.0}]},{"StartTime":10027.0,"Objects":[{"StartTime":10027.0,"EndTime":10027.0,"X":276.0,"Y":88.0}]},{"StartTime":10189.0,"Objects":[{"StartTime":10189.0,"EndTime":10189.0,"X":370.0,"Y":237.0}]},{"StartTime":10351.0,"Objects":[{"StartTime":10351.0,"EndTime":10351.0,"X":179.0,"Y":92.0}]},{"StartTime":10514.0,"Objects":[{"StartTime":10514.0,"EndTime":10514.0,"X":327.0,"Y":10.0}]},{"StartTime":10676.0,"Objects":[{"StartTime":10676.0,"EndTime":10676.0,"X":239.0,"Y":267.0}]},{"StartTime":10838.0,"Objects":[{"StartTime":10838.0,"EndTime":10838.0,"X":92.0,"Y":0.0}]},{"StartTime":11000.0,"Objects":[{"StartTime":11000.0,"EndTime":11000.0,"X":360.0,"Y":142.0}]},{"StartTime":11162.0,"Objects":[{"StartTime":11162.0,"EndTime":11162.0,"X":17.0,"Y":198.0}]},{"StartTime":11324.0,"Objects":[{"StartTime":11324.0,"EndTime":11324.0,"X":327.0,"Y":10.0}]},{"StartTime":11487.0,"Objects":[{"StartTime":11487.0,"EndTime":11487.0,"X":213.0,"Y":180.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11694.0,"EndTime":11694.0,"X":203.7394,"Y":124.416283,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":11811.0,"Objects":[{"StartTime":11811.0,"EndTime":11811.0,"X":179.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":12099.0,"EndTime":12099.0,"X":360.0068,"Y":142.989914,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":12297.0,"Objects":[{"StartTime":12297.0,"EndTime":12297.0,"X":161.0,"Y":247.0}]},{"StartTime":12460.0,"Objects":[{"StartTime":12460.0,"EndTime":12460.0,"X":333.0,"Y":351.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":12586.0,"EndTime":12586.0,"X":237.9624,"Y":377.402344,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":12784.0,"Objects":[{"StartTime":12784.0,"EndTime":12784.0,"X":355.440155,"Y":230.44014,"StackOffset":{"X":-3.559845,"Y":-3.55986023}},{"StartTime":12991.0,"EndTime":12991.0,"X":475.1857,"Y":275.841736,"StackOffset":{"X":-3.559845,"Y":-3.55986023}}]},{"StartTime":13108.0,"Objects":[{"StartTime":13108.0,"EndTime":13108.0,"X":478.0,"Y":279.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13234.0,"EndTime":13234.0,"X":465.9604,"Y":377.18808,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":13432.0,"Objects":[{"StartTime":13432.0,"EndTime":13432.0,"X":262.0,"Y":177.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13558.0,"EndTime":13558.0,"X":276.4605,"Y":275.036957,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":13757.0,"Objects":[{"StartTime":13757.0,"EndTime":13757.0,"X":462.0,"Y":114.0}]},{"StartTime":13919.0,"Objects":[{"StartTime":13919.0,"EndTime":13919.0,"X":330.0,"Y":16.0}]},{"StartTime":14081.0,"Objects":[{"StartTime":14081.0,"EndTime":14081.0,"X":424.0,"Y":194.0}]},{"StartTime":14243.0,"Objects":[{"StartTime":14243.0,"EndTime":14243.0,"X":492.88028,"Y":18.8802814}]},{"StartTime":14324.0,"Objects":[{"StartTime":14324.0,"EndTime":14324.0,"X":496.440155,"Y":22.4401417}]},{"StartTime":14405.0,"Objects":[{"StartTime":14405.0,"EndTime":14405.0,"X":500.0,"Y":26.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":14693.0,"EndTime":14693.0,"X":329.573761,"Y":91.81665,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":14892.0,"Objects":[{"StartTime":14892.0,"EndTime":14892.0,"X":194.0,"Y":239.0}]},{"StartTime":15054.0,"Objects":[{"StartTime":15054.0,"EndTime":15054.0,"X":145.0,"Y":179.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":15180.0,"EndTime":15180.0,"X":162.32164,"Y":81.54735,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":15378.0,"Objects":[{"StartTime":15378.0,"EndTime":15378.0,"X":334.0,"Y":205.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":15585.0,"EndTime":15585.0,"X":351.839874,"Y":352.5982,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":15703.0,"Objects":[{"StartTime":15703.0,"EndTime":15703.0,"X":280.0,"Y":384.0}]},{"StartTime":15865.0,"Objects":[{"StartTime":15865.0,"EndTime":15865.0,"X":428.0,"Y":267.0}]},{"StartTime":16027.0,"Objects":[{"StartTime":16027.0,"EndTime":16027.0,"X":267.0,"Y":152.0}]},{"StartTime":16189.0,"Objects":[{"StartTime":16189.0,"EndTime":16189.0,"X":435.0,"Y":350.0}]},{"StartTime":16351.0,"Objects":[{"StartTime":16351.0,"EndTime":16351.0,"X":512.0,"Y":117.0}]},{"StartTime":16514.0,"Objects":[{"StartTime":16514.0,"EndTime":16514.0,"X":317.0,"Y":254.0}]},{"StartTime":16595.0,"Objects":[{"StartTime":16595.0,"EndTime":16595.0,"X":274.0,"Y":268.0}]},{"StartTime":16676.0,"Objects":[{"StartTime":16676.0,"EndTime":16676.0,"X":231.0,"Y":240.0}]},{"StartTime":16757.0,"Objects":[{"StartTime":16757.0,"EndTime":16757.0,"X":225.0,"Y":184.0}]},{"StartTime":16838.0,"Objects":[{"StartTime":16838.0,"EndTime":16838.0,"X":267.0,"Y":152.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":16883.0,"EndTime":16883.0,"X":314.118317,"Y":167.4607,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":17000.0,"Objects":[{"StartTime":17000.0,"EndTime":17000.0,"X":163.0,"Y":356.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":17288.0,"EndTime":17288.0,"X":90.0459442,"Y":235.082169,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":17487.0,"Objects":[{"StartTime":17487.0,"EndTime":17487.0,"X":267.0,"Y":152.0}]},{"StartTime":17649.0,"Objects":[{"StartTime":17649.0,"EndTime":17649.0,"X":133.0,"Y":23.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":17775.0,"EndTime":17775.0,"X":149.881119,"Y":120.732796,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":17973.0,"Objects":[{"StartTime":17973.0,"EndTime":17973.0,"X":355.0,"Y":312.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":18180.0,"EndTime":18180.0,"X":388.59436,"Y":206.28183,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":18297.0,"Objects":[{"StartTime":18297.0,"EndTime":18297.0,"X":345.0,"Y":146.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":18585.0,"EndTime":18585.0,"X":167.093735,"Y":210.64801,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":18784.0,"Objects":[{"StartTime":18784.0,"EndTime":18784.0,"X":381.0,"Y":76.0}]},{"StartTime":18946.0,"Objects":[{"StartTime":18946.0,"EndTime":18946.0,"X":234.0,"Y":23.0}]},{"StartTime":19108.0,"Objects":[{"StartTime":19108.0,"EndTime":19108.0,"X":316.0,"Y":229.0}]},{"StartTime":19270.0,"Objects":[{"StartTime":19270.0,"EndTime":19270.0,"X":456.0,"Y":47.0}]},{"StartTime":19432.0,"Objects":[{"StartTime":19432.0,"EndTime":19432.0,"X":243.0,"Y":135.0}]},{"StartTime":19514.0,"Objects":[{"StartTime":19514.0,"EndTime":19514.0,"X":247.0,"Y":124.0}]},{"StartTime":19595.0,"Objects":[{"StartTime":19595.0,"EndTime":19595.0,"X":252.0,"Y":115.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":19721.0,"EndTime":19721.0,"X":345.13028,"Y":145.5859,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":19919.0,"Objects":[{"StartTime":19919.0,"EndTime":19919.0,"X":477.0,"Y":348.0}]},{"StartTime":20081.0,"Objects":[{"StartTime":20081.0,"EndTime":20081.0,"X":316.0,"Y":229.0}]},{"StartTime":20243.0,"Objects":[{"StartTime":20243.0,"EndTime":20243.0,"X":490.0,"Y":131.0}]},{"StartTime":20405.0,"Objects":[{"StartTime":20405.0,"EndTime":20405.0,"X":350.0,"Y":315.0}]},{"StartTime":20487.0,"Objects":[{"StartTime":20487.0,"EndTime":20487.0,"X":331.0,"Y":326.0}]},{"StartTime":20568.0,"Objects":[{"StartTime":20568.0,"EndTime":20568.0,"X":307.0,"Y":324.0}]},{"StartTime":20730.0,"Objects":[{"StartTime":20730.0,"EndTime":20730.0,"X":149.44014,"Y":172.44014}]},{"StartTime":21054.0,"Objects":[{"StartTime":21054.0,"EndTime":21054.0,"X":153.0,"Y":176.0}]},{"StartTime":21216.0,"Objects":[{"StartTime":21216.0,"EndTime":21216.0,"X":216.0,"Y":241.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":21504.0,"EndTime":21504.0,"X":195.1709,"Y":367.652924,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":21703.0,"Objects":[{"StartTime":21703.0,"EndTime":21703.0,"X":69.0,"Y":222.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":21829.0,"EndTime":21829.0,"X":81.0571747,"Y":285.124634,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":22189.0,"Objects":[{"StartTime":22189.0,"EndTime":22189.0,"X":109.0,"Y":82.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":22513.0,"EndTime":22513.0,"X":4.257408,"Y":47.9354248,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":22801.0,"EndTime":22801.0,"X":109.0,"Y":82.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":23000.0,"Objects":[{"StartTime":23000.0,"EndTime":23000.0,"X":172.0,"Y":127.0}]},{"StartTime":23162.0,"Objects":[{"StartTime":23162.0,"EndTime":23162.0,"X":277.0,"Y":0.0}]},{"StartTime":23324.0,"Objects":[{"StartTime":23324.0,"EndTime":23324.0,"X":209.0,"Y":33.0}]},{"StartTime":23487.0,"Objects":[{"StartTime":23487.0,"EndTime":23487.0,"X":330.0,"Y":148.0}]},{"StartTime":23649.0,"Objects":[{"StartTime":23649.0,"EndTime":23649.0,"X":379.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":23775.0,"EndTime":23775.0,"X":361.026459,"Y":30.37888,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":23973.0,"Objects":[{"StartTime":23973.0,"EndTime":23973.0,"X":230.0,"Y":185.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":24261.0,"EndTime":24261.0,"X":211.478638,"Y":312.3779,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":24460.0,"Objects":[{"StartTime":24460.0,"EndTime":24460.0,"X":57.0,"Y":132.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":24586.0,"EndTime":24586.0,"X":67.68643,"Y":195.35968,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":24784.0,"Objects":[{"StartTime":24784.0,"EndTime":24784.0,"X":0.0,"Y":365.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":25072.0,"EndTime":25072.0,"X":104.24324,"Y":330.343933,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":25432.0,"Objects":[{"StartTime":25432.0,"EndTime":25432.0,"X":352.0,"Y":146.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":25720.0,"EndTime":25720.0,"X":229.388351,"Y":184.353943,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":25919.0,"Objects":[{"StartTime":25919.0,"EndTime":25919.0,"X":346.0,"Y":306.0}]},{"StartTime":26081.0,"Objects":[{"StartTime":26081.0,"EndTime":26081.0,"X":283.0,"Y":346.0}]},{"StartTime":26243.0,"Objects":[{"StartTime":26243.0,"EndTime":26243.0,"X":352.0,"Y":229.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":26369.0,"EndTime":26369.0,"X":412.114777,"Y":251.14682,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":26568.0,"Objects":[{"StartTime":26568.0,"EndTime":26568.0,"X":257.0,"Y":75.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":26856.0,"EndTime":26856.0,"X":229.16745,"Y":182.521591,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":27054.0,"Objects":[{"StartTime":27054.0,"EndTime":27054.0,"X":401.0,"Y":73.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":27180.0,"EndTime":27180.0,"X":389.483368,"Y":9.658455,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":27378.0,"Objects":[{"StartTime":27378.0,"EndTime":27378.0,"X":486.0,"Y":170.0}]},{"StartTime":27541.0,"Objects":[{"StartTime":27541.0,"EndTime":27541.0,"X":291.88028,"Y":67.88028}]},{"StartTime":27622.0,"Objects":[{"StartTime":27622.0,"EndTime":27622.0,"X":295.440155,"Y":71.44014}]},{"StartTime":27703.0,"Objects":[{"StartTime":27703.0,"EndTime":27703.0,"X":299.0,"Y":75.0}]},{"StartTime":27865.0,"Objects":[{"StartTime":27865.0,"EndTime":27865.0,"X":352.0,"Y":229.0}]},{"StartTime":28027.0,"Objects":[{"StartTime":28027.0,"EndTime":28027.0,"X":480.88028,"Y":83.88028}]},{"StartTime":28108.0,"Objects":[{"StartTime":28108.0,"EndTime":28108.0,"X":484.440155,"Y":87.44014}]},{"StartTime":28189.0,"Objects":[{"StartTime":28189.0,"EndTime":28189.0,"X":488.0,"Y":91.0}]},{"StartTime":28351.0,"Objects":[{"StartTime":28351.0,"EndTime":28351.0,"X":302.0,"Y":157.0}]},{"StartTime":28514.0,"Objects":[{"StartTime":28514.0,"EndTime":28514.0,"X":492.0,"Y":249.0}]},{"StartTime":28676.0,"Objects":[{"StartTime":28676.0,"EndTime":28676.0,"X":249.0,"Y":350.0}]},{"StartTime":28838.0,"Objects":[{"StartTime":28838.0,"EndTime":28838.0,"X":351.88028,"Y":230.88028}]},{"StartTime":28919.0,"Objects":[{"StartTime":28919.0,"EndTime":28919.0,"X":355.440155,"Y":234.44014}]},{"StartTime":29000.0,"Objects":[{"StartTime":29000.0,"EndTime":29000.0,"X":359.0,"Y":238.0}]},{"StartTime":29162.0,"Objects":[{"StartTime":29162.0,"EndTime":29162.0,"X":399.0,"Y":383.0}]},{"StartTime":29324.0,"Objects":[{"StartTime":29324.0,"EndTime":29324.0,"X":216.88028,"Y":239.88028}]},{"StartTime":29405.0,"Objects":[{"StartTime":29405.0,"EndTime":29405.0,"X":220.44014,"Y":243.44014}]},{"StartTime":29487.0,"Objects":[{"StartTime":29487.0,"EndTime":29487.0,"X":224.0,"Y":247.0}]},{"StartTime":29649.0,"Objects":[{"StartTime":29649.0,"EndTime":29649.0,"X":370.0,"Y":257.0}]},{"StartTime":29811.0,"Objects":[{"StartTime":29811.0,"EndTime":29811.0,"X":159.0,"Y":384.0}]},{"StartTime":29973.0,"Objects":[{"StartTime":29973.0,"EndTime":29973.0,"X":329.0,"Y":329.0}]},{"StartTime":30135.0,"Objects":[{"StartTime":30135.0,"EndTime":30135.0,"X":217.0,"Y":278.0}]},{"StartTime":30297.0,"Objects":[{"StartTime":30297.0,"EndTime":30297.0,"X":254.0,"Y":376.0}]},{"StartTime":30460.0,"Objects":[{"StartTime":30460.0,"EndTime":30460.0,"X":340.0,"Y":232.0}]},{"StartTime":30622.0,"Objects":[{"StartTime":30622.0,"EndTime":30622.0,"X":176.0,"Y":159.0}]},{"StartTime":30784.0,"Objects":[{"StartTime":30784.0,"EndTime":30784.0,"X":329.0,"Y":329.0}]},{"StartTime":30946.0,"Objects":[{"StartTime":30946.0,"EndTime":30946.0,"X":124.0,"Y":235.0}]},{"StartTime":31108.0,"Objects":[{"StartTime":31108.0,"EndTime":31108.0,"X":390.0,"Y":102.0}]},{"StartTime":31270.0,"Objects":[{"StartTime":31270.0,"EndTime":31270.0,"X":254.0,"Y":376.0}]},{"StartTime":31432.0,"Objects":[{"StartTime":31432.0,"EndTime":31432.0,"X":176.0,"Y":36.0}]},{"StartTime":32081.0,"Objects":[{"StartTime":32081.0,"EndTime":32081.0,"X":11.0,"Y":207.0}]},{"StartTime":32162.0,"Objects":[{"StartTime":32162.0,"EndTime":32162.0,"X":9.0,"Y":195.0}]},{"StartTime":32243.0,"Objects":[{"StartTime":32243.0,"EndTime":32243.0,"X":12.0,"Y":183.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":32450.0,"EndTime":32450.0,"X":165.248047,"Y":205.856049,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":32568.0,"Objects":[{"StartTime":32568.0,"EndTime":32568.0,"X":230.0,"Y":258.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":32694.0,"EndTime":32694.0,"X":241.458481,"Y":338.973267,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":32892.0,"Objects":[{"StartTime":32892.0,"EndTime":32892.0,"X":412.0,"Y":228.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":33018.0,"EndTime":33018.0,"X":427.511627,"Y":147.704559,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":33216.0,"Objects":[{"StartTime":33216.0,"EndTime":33216.0,"X":289.0,"Y":55.0}]},{"StartTime":33378.0,"Objects":[{"StartTime":33378.0,"EndTime":33378.0,"X":307.0,"Y":126.0}]},{"StartTime":33541.0,"Objects":[{"StartTime":33541.0,"EndTime":33541.0,"X":420.0,"Y":29.0}]},{"StartTime":33703.0,"Objects":[{"StartTime":33703.0,"EndTime":33703.0,"X":356.0,"Y":0.0}]},{"StartTime":33865.0,"Objects":[{"StartTime":33865.0,"EndTime":33865.0,"X":228.0,"Y":103.0}]},{"StartTime":34027.0,"Objects":[{"StartTime":34027.0,"EndTime":34027.0,"X":169.0,"Y":156.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":34153.0,"EndTime":34153.0,"X":89.55082,"Y":173.8051,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":34351.0,"Objects":[{"StartTime":34351.0,"EndTime":34351.0,"X":225.0,"Y":321.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":34558.0,"EndTime":34558.0,"X":330.9766,"Y":360.775024,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":34676.0,"Objects":[{"StartTime":34676.0,"EndTime":34676.0,"X":394.0,"Y":318.0}]},{"StartTime":34838.0,"Objects":[{"StartTime":34838.0,"EndTime":34838.0,"X":456.0,"Y":156.0}]},{"StartTime":35000.0,"Objects":[{"StartTime":35000.0,"EndTime":35000.0,"X":387.0,"Y":182.0}]},{"StartTime":35162.0,"Objects":[{"StartTime":35162.0,"EndTime":35162.0,"X":491.0,"Y":363.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":35288.0,"EndTime":35288.0,"X":500.417938,"Y":282.124268,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":35487.0,"Objects":[{"StartTime":35487.0,"EndTime":35487.0,"X":302.0,"Y":149.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":35613.0,"EndTime":35613.0,"X":313.509155,"Y":229.585571,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":35811.0,"Objects":[{"StartTime":35811.0,"EndTime":35811.0,"X":456.0,"Y":63.0}]},{"StartTime":35973.0,"Objects":[{"StartTime":35973.0,"EndTime":35973.0,"X":386.0,"Y":37.0}]},{"StartTime":36135.0,"Objects":[{"StartTime":36135.0,"EndTime":36135.0,"X":456.0,"Y":156.0}]},{"StartTime":36297.0,"Objects":[{"StartTime":36297.0,"EndTime":36297.0,"X":387.0,"Y":182.0}]},{"StartTime":36460.0,"Objects":[{"StartTime":36460.0,"EndTime":36460.0,"X":456.0,"Y":63.0}]},{"StartTime":36622.0,"Objects":[{"StartTime":36622.0,"EndTime":36622.0,"X":302.0,"Y":149.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":36748.0,"EndTime":36748.0,"X":231.357132,"Y":120.073067,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":36946.0,"Objects":[{"StartTime":36946.0,"EndTime":36946.0,"X":127.0,"Y":34.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":37234.0,"EndTime":37234.0,"X":104.682526,"Y":195.795059,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":37432.0,"Objects":[{"StartTime":37432.0,"EndTime":37432.0,"X":229.0,"Y":33.0}]},{"StartTime":37595.0,"Objects":[{"StartTime":37595.0,"EndTime":37595.0,"X":-7.119718,"Y":148.88028}]},{"StartTime":37676.0,"Objects":[{"StartTime":37676.0,"EndTime":37676.0,"X":-3.559859,"Y":152.44014}]},{"StartTime":37757.0,"Objects":[{"StartTime":37757.0,"EndTime":37757.0,"X":0.0,"Y":156.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":38045.0,"EndTime":38045.0,"X":105.247383,"Y":196.403824,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":38243.0,"Objects":[{"StartTime":38243.0,"EndTime":38243.0,"X":223.0,"Y":324.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":38369.0,"EndTime":38369.0,"X":145.465851,"Y":298.830627,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":38568.0,"Objects":[{"StartTime":38568.0,"EndTime":38568.0,"X":261.0,"Y":217.0}]},{"StartTime":38730.0,"Objects":[{"StartTime":38730.0,"EndTime":38730.0,"X":105.0,"Y":196.0}]},{"StartTime":38892.0,"Objects":[{"StartTime":38892.0,"EndTime":38892.0,"X":159.0,"Y":384.0}]},{"StartTime":39054.0,"Objects":[{"StartTime":39054.0,"EndTime":39054.0,"X":57.0,"Y":263.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":39180.0,"EndTime":39180.0,"X":44.4304,"Y":343.747,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":39378.0,"Objects":[{"StartTime":39378.0,"EndTime":39378.0,"X":185.0,"Y":183.0}]},{"StartTime":39541.0,"Objects":[{"StartTime":39541.0,"EndTime":39541.0,"X":344.0,"Y":308.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":39829.0,"EndTime":39829.0,"X":326.713867,"Y":145.787735,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":40027.0,"Objects":[{"StartTime":40027.0,"EndTime":40027.0,"X":223.0,"Y":324.0}]},{"StartTime":40189.0,"Objects":[{"StartTime":40189.0,"EndTime":40189.0,"X":436.0,"Y":193.0}]},{"StartTime":40351.0,"Objects":[{"StartTime":40351.0,"EndTime":40351.0,"X":244.0,"Y":93.0}]},{"StartTime":40514.0,"Objects":[{"StartTime":40514.0,"EndTime":40514.0,"X":413.88028,"Y":280.88028}]},{"StartTime":40595.0,"Objects":[{"StartTime":40595.0,"EndTime":40595.0,"X":417.440155,"Y":284.440155}]},{"StartTime":40676.0,"Objects":[{"StartTime":40676.0,"EndTime":40676.0,"X":421.0,"Y":288.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":40883.0,"EndTime":40883.0,"X":343.534119,"Y":307.5015,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":41000.0,"Objects":[{"StartTime":41000.0,"EndTime":41000.0,"X":103.0,"Y":145.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":41288.0,"EndTime":41288.0,"X":199.702286,"Y":178.3348,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":41487.0,"Objects":[{"StartTime":41487.0,"EndTime":41487.0,"X":410.88028,"Y":12.8802814}]},{"StartTime":41568.0,"Objects":[{"StartTime":41568.0,"EndTime":41568.0,"X":414.440155,"Y":16.4401417}]},{"StartTime":41649.0,"Objects":[{"StartTime":41649.0,"EndTime":41649.0,"X":418.0,"Y":20.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":41856.0,"EndTime":41856.0,"X":316.865723,"Y":65.63147,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":41973.0,"Objects":[{"StartTime":41973.0,"EndTime":41973.0,"X":375.0,"Y":116.0}]},{"StartTime":42135.0,"Objects":[{"StartTime":42135.0,"EndTime":42135.0,"X":181.0,"Y":32.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":42261.0,"EndTime":42261.0,"X":80.84002,"Y":-0.0190725327,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":42460.0,"Objects":[{"StartTime":42460.0,"EndTime":42460.0,"X":280.0,"Y":169.0}]},{"StartTime":42622.0,"Objects":[{"StartTime":42622.0,"EndTime":42622.0,"X":78.0,"Y":332.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":42829.0,"EndTime":42829.0,"X":123.77272,"Y":225.780655,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":42946.0,"Objects":[{"StartTime":42946.0,"EndTime":42946.0,"X":189.0,"Y":264.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":42991.0,"EndTime":42991.0,"X":240.3147,"Y":280.623077,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":43108.0,"Objects":[{"StartTime":43108.0,"EndTime":43108.0,"X":381.0,"Y":384.0}]},{"StartTime":43270.0,"Objects":[{"StartTime":43270.0,"EndTime":43270.0,"X":444.0,"Y":339.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":43396.0,"EndTime":43396.0,"X":455.20694,"Y":284.4595,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":43595.0,"Objects":[{"StartTime":43595.0,"EndTime":43595.0,"X":280.0,"Y":169.0}]},{"StartTime":43757.0,"Objects":[{"StartTime":43757.0,"EndTime":43757.0,"X":397.440155,"Y":165.44014}]},{"StartTime":43919.0,"Objects":[{"StartTime":43919.0,"EndTime":43919.0,"X":240.0,"Y":280.0}]},{"StartTime":44081.0,"Objects":[{"StartTime":44081.0,"EndTime":44081.0,"X":401.0,"Y":169.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":44369.0,"EndTime":44369.0,"X":323.41684,"Y":41.0436325,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":44568.0,"Objects":[{"StartTime":44568.0,"EndTime":44568.0,"X":455.0,"Y":284.0}]},{"StartTime":44730.0,"Objects":[{"StartTime":44730.0,"EndTime":44730.0,"X":280.0,"Y":169.0}]},{"StartTime":44892.0,"Objects":[{"StartTime":44892.0,"EndTime":44892.0,"X":446.0,"Y":58.0}]},{"StartTime":45054.0,"Objects":[{"StartTime":45054.0,"EndTime":45054.0,"X":313.88028,"Y":237.88028}]},{"StartTime":45135.0,"Objects":[{"StartTime":45135.0,"EndTime":45135.0,"X":317.440155,"Y":241.44014}]},{"StartTime":45216.0,"Objects":[{"StartTime":45216.0,"EndTime":45216.0,"X":321.0,"Y":245.0}]},{"StartTime":45378.0,"Objects":[{"StartTime":45378.0,"EndTime":45378.0,"X":224.0,"Y":19.0}]},{"StartTime":45541.0,"Objects":[{"StartTime":45541.0,"EndTime":45541.0,"X":401.0,"Y":169.0}]},{"StartTime":45703.0,"Objects":[{"StartTime":45703.0,"EndTime":45703.0,"X":144.0,"Y":285.0}]},{"StartTime":45865.0,"Objects":[{"StartTime":45865.0,"EndTime":45865.0,"X":98.0,"Y":221.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":45991.0,"EndTime":45991.0,"X":108.747459,"Y":166.3671,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":46108.0,"Objects":[{"StartTime":46108.0,"EndTime":46108.0,"X":127.0,"Y":95.0}]},{"StartTime":46189.0,"Objects":[{"StartTime":46189.0,"EndTime":46189.0,"X":130.0,"Y":81.0}]},{"StartTime":46351.0,"Objects":[{"StartTime":46351.0,"EndTime":46351.0,"X":0.0,"Y":173.0}]},{"StartTime":46514.0,"Objects":[{"StartTime":46514.0,"EndTime":46514.0,"X":133.0,"Y":68.0}]},{"StartTime":46676.0,"Objects":[{"StartTime":46676.0,"EndTime":46676.0,"X":273.0,"Y":240.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":46883.0,"EndTime":46883.0,"X":143.723663,"Y":284.335815,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":47000.0,"Objects":[{"StartTime":47000.0,"EndTime":47000.0,"X":98.0,"Y":221.0}]},{"StartTime":47162.0,"Objects":[{"StartTime":47162.0,"EndTime":47162.0,"X":278.0,"Y":94.0}]},{"StartTime":47324.0,"Objects":[{"StartTime":47324.0,"EndTime":47324.0,"X":216.0,"Y":52.0}]},{"StartTime":47487.0,"Objects":[{"StartTime":47487.0,"EndTime":47487.0,"X":325.0,"Y":319.0}]},{"StartTime":47649.0,"Objects":[{"StartTime":47649.0,"EndTime":47649.0,"X":193.0,"Y":209.0}]},{"StartTime":47730.0,"Objects":[{"StartTime":47730.0,"EndTime":47730.0,"X":195.0,"Y":195.0}]},{"StartTime":47811.0,"Objects":[{"StartTime":47811.0,"EndTime":47811.0,"X":198.0,"Y":180.0}]},{"StartTime":47973.0,"Objects":[{"StartTime":47973.0,"EndTime":47973.0,"X":356.0,"Y":70.0}]},{"StartTime":48135.0,"Objects":[{"StartTime":48135.0,"EndTime":48135.0,"X":424.0,"Y":119.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":48261.0,"EndTime":48261.0,"X":437.1501,"Y":190.887146,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":48378.0,"Objects":[{"StartTime":48378.0,"EndTime":48378.0,"X":452.0,"Y":263.0}]},{"StartTime":48460.0,"Objects":[{"StartTime":48460.0,"EndTime":48460.0,"X":455.0,"Y":283.0}]},{"StartTime":48622.0,"Objects":[{"StartTime":48622.0,"EndTime":48622.0,"X":338.0,"Y":197.0}]},{"StartTime":48784.0,"Objects":[{"StartTime":48784.0,"EndTime":48784.0,"X":235.0,"Y":343.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":48946.0,"EndTime":48946.0,"X":249.644958,"Y":271.402435,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":49072.0,"EndTime":49072.0,"X":235.0,"Y":343.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":49270.0,"Objects":[{"StartTime":49270.0,"EndTime":49270.0,"X":48.8802834,"Y":199.88028}]},{"StartTime":49351.0,"Objects":[{"StartTime":49351.0,"EndTime":49351.0,"X":52.44014,"Y":203.44014}]},{"StartTime":49432.0,"Objects":[{"StartTime":49432.0,"EndTime":49432.0,"X":56.0,"Y":207.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":49558.0,"EndTime":49558.0,"X":127.47998,"Y":191.791489,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":49676.0,"Objects":[{"StartTime":49676.0,"EndTime":49676.0,"X":198.0,"Y":180.0}]},{"StartTime":49757.0,"Objects":[{"StartTime":49757.0,"EndTime":49757.0,"X":222.0,"Y":175.0}]},{"StartTime":49919.0,"Objects":[{"StartTime":49919.0,"EndTime":49919.0,"X":344.0,"Y":84.0}]},{"StartTime":50081.0,"Objects":[{"StartTime":50081.0,"EndTime":50081.0,"X":239.0,"Y":6.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":50207.0,"EndTime":50207.0,"X":249.335068,"Y":78.34551,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":50405.0,"Objects":[{"StartTime":50405.0,"EndTime":50405.0,"X":385.0,"Y":177.0}]},{"StartTime":50568.0,"Objects":[{"StartTime":50568.0,"EndTime":50568.0,"X":465.88028,"Y":67.88028}]},{"StartTime":50649.0,"Objects":[{"StartTime":50649.0,"EndTime":50649.0,"X":469.440155,"Y":71.44014}]},{"StartTime":50730.0,"Objects":[{"StartTime":50730.0,"EndTime":50730.0,"X":473.0,"Y":75.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":50856.0,"EndTime":50856.0,"X":464.6807,"Y":147.604919,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":50973.0,"Objects":[{"StartTime":50973.0,"EndTime":50973.0,"X":453.0,"Y":220.0}]},{"StartTime":51054.0,"Objects":[{"StartTime":51054.0,"EndTime":51054.0,"X":451.0,"Y":236.0}]},{"StartTime":51216.0,"Objects":[{"StartTime":51216.0,"EndTime":51216.0,"X":321.0,"Y":132.0}]},{"StartTime":51378.0,"Objects":[{"StartTime":51378.0,"EndTime":51378.0,"X":218.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":51504.0,"EndTime":51504.0,"X":289.1127,"Y":263.157532,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":51703.0,"Objects":[{"StartTime":51703.0,"EndTime":51703.0,"X":198.0,"Y":180.0}]},{"StartTime":51865.0,"Objects":[{"StartTime":51865.0,"EndTime":51865.0,"X":289.88028,"Y":264.88028}]},{"StartTime":51946.0,"Objects":[{"StartTime":51946.0,"EndTime":51946.0,"X":293.440155,"Y":268.440155}]},{"StartTime":52027.0,"Objects":[{"StartTime":52027.0,"EndTime":52027.0,"X":297.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":52153.0,"EndTime":52153.0,"X":287.091919,"Y":344.4052,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":52270.0,"Objects":[{"StartTime":52270.0,"EndTime":52270.0,"X":223.44014,"Y":380.440155}]},{"StartTime":52351.0,"Objects":[{"StartTime":52351.0,"EndTime":52351.0,"X":227.0,"Y":384.0}]},{"StartTime":52514.0,"Objects":[{"StartTime":52514.0,"EndTime":52514.0,"X":55.8802834,"Y":234.88028}]},{"StartTime":52595.0,"Objects":[{"StartTime":52595.0,"EndTime":52595.0,"X":59.44014,"Y":238.44014}]},{"StartTime":52676.0,"Objects":[{"StartTime":52676.0,"EndTime":52676.0,"X":63.0,"Y":242.0}]},{"StartTime":52757.0,"Objects":[{"StartTime":52757.0,"EndTime":52757.0,"X":91.0,"Y":196.0}]},{"StartTime":52838.0,"Objects":[{"StartTime":52838.0,"EndTime":52838.0,"X":138.0,"Y":186.0}]},{"StartTime":52919.0,"Objects":[{"StartTime":52919.0,"EndTime":52919.0,"X":186.0,"Y":209.0}]},{"StartTime":53000.0,"Objects":[{"StartTime":53000.0,"EndTime":53000.0,"X":187.0,"Y":230.0}]},{"StartTime":53081.0,"Objects":[{"StartTime":53081.0,"EndTime":53081.0,"X":234.0,"Y":278.0}]},{"StartTime":53162.0,"Objects":[{"StartTime":53162.0,"EndTime":53162.0,"X":297.0,"Y":272.0}]},{"StartTime":53243.0,"Objects":[{"StartTime":53243.0,"EndTime":53243.0,"X":330.0,"Y":217.0}]},{"StartTime":53324.0,"Objects":[{"StartTime":53324.0,"EndTime":53324.0,"X":291.440155,"Y":157.44014}]},{"StartTime":53649.0,"Objects":[{"StartTime":53649.0,"EndTime":53649.0,"X":295.0,"Y":161.0}]},{"StartTime":53811.0,"Objects":[{"StartTime":53811.0,"EndTime":53811.0,"X":401.0,"Y":308.0}]},{"StartTime":53973.0,"Objects":[{"StartTime":53973.0,"EndTime":53973.0,"X":187.0,"Y":230.0}]},{"StartTime":54135.0,"Objects":[{"StartTime":54135.0,"EndTime":54135.0,"X":383.0,"Y":113.0}]},{"StartTime":54297.0,"Objects":[{"StartTime":54297.0,"EndTime":54297.0,"X":268.0,"Y":345.0}]},{"StartTime":54460.0,"Objects":[{"StartTime":54460.0,"EndTime":54460.0,"X":181.0,"Y":103.0}]},{"StartTime":54622.0,"Objects":[{"StartTime":54622.0,"EndTime":54622.0,"X":353.0,"Y":192.0}]},{"StartTime":54784.0,"Objects":[{"StartTime":54784.0,"EndTime":54784.0,"X":125.0,"Y":325.0}]},{"StartTime":54946.0,"Objects":[{"StartTime":54946.0,"EndTime":54946.0,"X":253.0,"Y":53.0}]},{"StartTime":55108.0,"Objects":[{"StartTime":55108.0,"EndTime":55108.0,"X":349.0,"Y":356.0}]},{"StartTime":55270.0,"Objects":[{"StartTime":55270.0,"EndTime":55270.0,"X":107.0,"Y":207.0}]},{"StartTime":55432.0,"Objects":[{"StartTime":55432.0,"EndTime":55432.0,"X":408.0,"Y":39.0}]},{"StartTime":55595.0,"Objects":[{"StartTime":55595.0,"EndTime":55595.0,"X":93.0,"Y":84.0}]},{"StartTime":55757.0,"Objects":[{"StartTime":55757.0,"EndTime":55757.0,"X":434.0,"Y":284.0}]},{"StartTime":55919.0,"Objects":[{"StartTime":55919.0,"EndTime":55919.0,"X":189.0,"Y":384.0}]},{"StartTime":56081.0,"Objects":[{"StartTime":56081.0,"EndTime":56081.0,"X":261.0,"Y":22.0}]},{"StartTime":56730.0,"Objects":[{"StartTime":56730.0,"EndTime":56730.0,"X":261.0,"Y":22.0}]},{"StartTime":56892.0,"Objects":[{"StartTime":56892.0,"EndTime":56892.0,"X":349.0,"Y":270.0}]},{"StartTime":57054.0,"Objects":[{"StartTime":57054.0,"EndTime":57054.0,"X":403.0,"Y":210.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":57261.0,"EndTime":57261.0,"X":266.4971,"Y":168.565536,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":57378.0,"Objects":[{"StartTime":57378.0,"EndTime":57378.0,"X":192.0,"Y":141.0}]},{"StartTime":57541.0,"Objects":[{"StartTime":57541.0,"EndTime":57541.0,"X":0.0,"Y":284.0}]},{"StartTime":57703.0,"Objects":[{"StartTime":57703.0,"EndTime":57703.0,"X":94.0,"Y":384.0}]},{"StartTime":57865.0,"Objects":[{"StartTime":57865.0,"EndTime":57865.0,"X":3.880282,"Y":172.88028}]},{"StartTime":57946.0,"Objects":[{"StartTime":57946.0,"EndTime":57946.0,"X":7.44014072,"Y":176.44014}]},{"StartTime":58027.0,"Objects":[{"StartTime":58027.0,"EndTime":58027.0,"X":11.0,"Y":180.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58072.0,"EndTime":58072.0,"X":60.9864349,"Y":199.046555,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":58189.0,"Objects":[{"StartTime":58189.0,"EndTime":58189.0,"X":227.0,"Y":293.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58396.0,"EndTime":58396.0,"X":176.743668,"Y":315.771759,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":58514.0,"Objects":[{"StartTime":58514.0,"EndTime":58514.0,"X":117.0,"Y":261.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58802.0,"EndTime":58802.0,"X":290.002,"Y":336.26297,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59000.0,"Objects":[{"StartTime":59000.0,"EndTime":59000.0,"X":403.0,"Y":210.0}]},{"StartTime":59162.0,"Objects":[{"StartTime":59162.0,"EndTime":59162.0,"X":512.0,"Y":346.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59288.0,"EndTime":59288.0,"X":419.275238,"Y":315.02655,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59487.0,"Objects":[{"StartTime":59487.0,"EndTime":59487.0,"X":292.0,"Y":168.0}]},{"StartTime":59649.0,"Objects":[{"StartTime":59649.0,"EndTime":59649.0,"X":438.88028,"Y":99.88028}]},{"StartTime":59730.0,"Objects":[{"StartTime":59730.0,"EndTime":59730.0,"X":442.440155,"Y":103.44014}]},{"StartTime":59811.0,"Objects":[{"StartTime":59811.0,"EndTime":59811.0,"X":446.0,"Y":107.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60099.0,"EndTime":60099.0,"X":291.098572,"Y":167.505417,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":60297.0,"Objects":[{"StartTime":60297.0,"EndTime":60297.0,"X":117.0,"Y":261.0}]},{"StartTime":60460.0,"Objects":[{"StartTime":60460.0,"EndTime":60460.0,"X":38.0,"Y":79.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60586.0,"EndTime":60586.0,"X":130.721649,"Y":113.926353,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":60784.0,"Objects":[{"StartTime":60784.0,"EndTime":60784.0,"X":249.0,"Y":284.0}]},{"StartTime":60946.0,"Objects":[{"StartTime":60946.0,"EndTime":60946.0,"X":43.8802834,"Y":358.88028}]},{"StartTime":61027.0,"Objects":[{"StartTime":61027.0,"EndTime":61027.0,"X":47.44014,"Y":362.440155}]},{"StartTime":61108.0,"Objects":[{"StartTime":61108.0,"EndTime":61108.0,"X":51.0,"Y":366.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":61396.0,"EndTime":61396.0,"X":53.8655739,"Y":214.24295,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":61595.0,"Objects":[{"StartTime":61595.0,"EndTime":61595.0,"X":189.0,"Y":366.0}]},{"StartTime":61757.0,"Objects":[{"StartTime":61757.0,"EndTime":61757.0,"X":197.0,"Y":181.0}]},{"StartTime":61919.0,"Objects":[{"StartTime":61919.0,"EndTime":61919.0,"X":25.0,"Y":290.0}]},{"StartTime":62081.0,"Objects":[{"StartTime":62081.0,"EndTime":62081.0,"X":130.0,"Y":113.0}]},{"StartTime":62243.0,"Objects":[{"StartTime":62243.0,"EndTime":62243.0,"X":247.88028,"Y":282.88028}]},{"StartTime":62324.0,"Objects":[{"StartTime":62324.0,"EndTime":62324.0,"X":251.44014,"Y":286.440155}]},{"StartTime":62405.0,"Objects":[{"StartTime":62405.0,"EndTime":62405.0,"X":255.0,"Y":290.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":62612.0,"EndTime":62612.0,"X":387.1793,"Y":342.8401,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":62730.0,"Objects":[{"StartTime":62730.0,"EndTime":62730.0,"X":448.0,"Y":294.0}]},{"StartTime":62892.0,"Objects":[{"StartTime":62892.0,"EndTime":62892.0,"X":266.0,"Y":384.0}]},{"StartTime":63054.0,"Objects":[{"StartTime":63054.0,"EndTime":63054.0,"X":330.0,"Y":157.0}]},{"StartTime":63216.0,"Objects":[{"StartTime":63216.0,"EndTime":63216.0,"X":464.0,"Y":369.0}]},{"StartTime":63297.0,"Objects":[{"StartTime":63297.0,"EndTime":63297.0,"X":476.0,"Y":372.0}]},{"StartTime":63378.0,"Objects":[{"StartTime":63378.0,"EndTime":63378.0,"X":486.0,"Y":376.0}]},{"StartTime":63541.0,"Objects":[{"StartTime":63541.0,"EndTime":63541.0,"X":332.88028,"Y":236.88028}]},{"StartTime":63622.0,"Objects":[{"StartTime":63622.0,"EndTime":63622.0,"X":336.440155,"Y":240.44014}]},{"StartTime":63703.0,"Objects":[{"StartTime":63703.0,"EndTime":63703.0,"X":340.0,"Y":244.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":63991.0,"EndTime":63991.0,"X":159.834656,"Y":305.091217,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":64189.0,"Objects":[{"StartTime":64189.0,"EndTime":64189.0,"X":330.0,"Y":157.0}]},{"StartTime":64351.0,"Objects":[{"StartTime":64351.0,"EndTime":64351.0,"X":191.0,"Y":35.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":64477.0,"EndTime":64477.0,"X":277.459381,"Y":65.24982,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":64676.0,"Objects":[{"StartTime":64676.0,"EndTime":64676.0,"X":114.0,"Y":169.0}]},{"StartTime":64838.0,"Objects":[{"StartTime":64838.0,"EndTime":64838.0,"X":276.0,"Y":284.0}]},{"StartTime":65000.0,"Objects":[{"StartTime":65000.0,"EndTime":65000.0,"X":191.0,"Y":35.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":65126.0,"EndTime":65126.0,"X":203.19455,"Y":133.42746,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":65324.0,"Objects":[{"StartTime":65324.0,"EndTime":65324.0,"X":69.0,"Y":328.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":65531.0,"EndTime":65531.0,"X":50.3194847,"Y":213.134308,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":65649.0,"Objects":[{"StartTime":65649.0,"EndTime":65649.0,"X":114.0,"Y":169.0}]},{"StartTime":65811.0,"Objects":[{"StartTime":65811.0,"EndTime":65811.0,"X":276.0,"Y":284.0}]},{"StartTime":65973.0,"Objects":[{"StartTime":65973.0,"EndTime":65973.0,"X":385.0,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":66099.0,"EndTime":66099.0,"X":291.745331,"Y":168.527618,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":66297.0,"Objects":[{"StartTime":66297.0,"EndTime":66297.0,"X":426.0,"Y":382.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":66585.0,"EndTime":66585.0,"X":449.0037,"Y":226.493729,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":66784.0,"Objects":[{"StartTime":66784.0,"EndTime":66784.0,"X":277.0,"Y":65.0}]},{"StartTime":66946.0,"Objects":[{"StartTime":66946.0,"EndTime":66946.0,"X":466.0,"Y":140.0}]},{"StartTime":67108.0,"Objects":[{"StartTime":67108.0,"EndTime":67108.0,"X":276.0,"Y":284.0}]},{"StartTime":67270.0,"Objects":[{"StartTime":67270.0,"EndTime":67270.0,"X":370.0,"Y":44.0}]},{"StartTime":67432.0,"Objects":[{"StartTime":67432.0,"EndTime":67432.0,"X":449.0,"Y":226.0}]},{"StartTime":67595.0,"Objects":[{"StartTime":67595.0,"EndTime":67595.0,"X":218.0,"Y":127.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67802.0,"EndTime":67802.0,"X":78.44003,"Y":170.844543,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":67919.0,"Objects":[{"StartTime":67919.0,"EndTime":67919.0,"X":17.0,"Y":215.0}]},{"StartTime":68000.0,"Objects":[{"StartTime":68000.0,"EndTime":68000.0,"X":0.0,"Y":184.0}]},{"StartTime":68081.0,"Objects":[{"StartTime":68081.0,"EndTime":68081.0,"X":12.0,"Y":147.0}]},{"StartTime":68243.0,"Objects":[{"StartTime":68243.0,"EndTime":68243.0,"X":178.44014,"Y":264.440155}]},{"StartTime":68405.0,"Objects":[{"StartTime":68405.0,"EndTime":68405.0,"X":5.0,"Y":376.0}]},{"StartTime":68568.0,"Objects":[{"StartTime":68568.0,"EndTime":68568.0,"X":182.0,"Y":268.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":68775.0,"EndTime":68775.0,"X":308.081726,"Y":309.018158,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":68892.0,"Objects":[{"StartTime":68892.0,"EndTime":68892.0,"X":352.0,"Y":379.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":69180.0,"EndTime":69180.0,"X":322.055725,"Y":182.913208,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":69378.0,"Objects":[{"StartTime":69378.0,"EndTime":69378.0,"X":452.0,"Y":288.0}]},{"StartTime":69541.0,"Objects":[{"StartTime":69541.0,"EndTime":69541.0,"X":236.0,"Y":384.0}]},{"StartTime":69703.0,"Objects":[{"StartTime":69703.0,"EndTime":69703.0,"X":428.0,"Y":187.0}]},{"StartTime":69865.0,"Objects":[{"StartTime":69865.0,"EndTime":69865.0,"X":449.0,"Y":384.0}]},{"StartTime":70027.0,"Objects":[{"StartTime":70027.0,"EndTime":70027.0,"X":229.88028,"Y":175.88028}]},{"StartTime":70108.0,"Objects":[{"StartTime":70108.0,"EndTime":70108.0,"X":233.44014,"Y":179.44014}]},{"StartTime":70189.0,"Objects":[{"StartTime":70189.0,"EndTime":70189.0,"X":237.0,"Y":183.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":70396.0,"EndTime":70396.0,"X":311.577423,"Y":106.287315,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":70514.0,"Objects":[{"StartTime":70514.0,"EndTime":70514.0,"X":302.0,"Y":117.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":70640.0,"EndTime":70640.0,"X":210.930847,"Y":150.767288,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":70838.0,"Objects":[{"StartTime":70838.0,"EndTime":70838.0,"X":335.0,"Y":2.0}]},{"StartTime":71000.0,"Objects":[{"StartTime":71000.0,"EndTime":71000.0,"X":428.88028,"Y":187.88028}]},{"StartTime":71081.0,"Objects":[{"StartTime":71081.0,"EndTime":71081.0,"X":432.440155,"Y":191.44014}]},{"StartTime":71162.0,"Objects":[{"StartTime":71162.0,"EndTime":71162.0,"X":436.0,"Y":195.0}]},{"StartTime":71324.0,"Objects":[{"StartTime":71324.0,"EndTime":71324.0,"X":311.0,"Y":106.0}]},{"StartTime":71487.0,"Objects":[{"StartTime":71487.0,"EndTime":71487.0,"X":224.0,"Y":322.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":71775.0,"EndTime":71775.0,"X":95.6513062,"Y":234.867325,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":71973.0,"Objects":[{"StartTime":71973.0,"EndTime":71973.0,"X":49.0,"Y":175.0}]},{"StartTime":72135.0,"Objects":[{"StartTime":72135.0,"EndTime":72135.0,"X":177.0,"Y":84.0}]},{"StartTime":72297.0,"Objects":[{"StartTime":72297.0,"EndTime":72297.0,"X":20.0,"Y":260.0}]},{"StartTime":72460.0,"Objects":[{"StartTime":72460.0,"EndTime":72460.0,"X":86.0,"Y":94.0}]},{"StartTime":72622.0,"Objects":[{"StartTime":72622.0,"EndTime":72622.0,"X":241.0,"Y":246.0}]},{"StartTime":72784.0,"Objects":[{"StartTime":72784.0,"EndTime":72784.0,"X":80.0,"Y":332.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":72910.0,"EndTime":72910.0,"X":95.6374,"Y":234.060516,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":73108.0,"Objects":[{"StartTime":73108.0,"EndTime":73108.0,"X":306.0,"Y":124.0}]},{"StartTime":73270.0,"Objects":[{"StartTime":73270.0,"EndTime":73270.0,"X":154.0,"Y":169.0}]},{"StartTime":73432.0,"Objects":[{"StartTime":73432.0,"EndTime":73432.0,"X":342.0,"Y":313.0}]},{"StartTime":73595.0,"Objects":[{"StartTime":73595.0,"EndTime":73595.0,"X":215.0,"Y":354.0}]},{"StartTime":73676.0,"Objects":[{"StartTime":73676.0,"EndTime":73676.0,"X":199.0,"Y":318.0}]},{"StartTime":73757.0,"Objects":[{"StartTime":73757.0,"EndTime":73757.0,"X":214.0,"Y":277.0}]},{"StartTime":73919.0,"Objects":[{"StartTime":73919.0,"EndTime":73919.0,"X":417.0,"Y":248.0}]},{"StartTime":74000.0,"Objects":[{"StartTime":74000.0,"EndTime":74000.0,"X":428.0,"Y":270.0}]},{"StartTime":74081.0,"Objects":[{"StartTime":74081.0,"EndTime":74081.0,"X":413.0,"Y":298.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":74207.0,"EndTime":74207.0,"X":341.732971,"Y":312.8005,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":74405.0,"Objects":[{"StartTime":74405.0,"EndTime":74405.0,"X":464.0,"Y":175.0}]},{"StartTime":74568.0,"Objects":[{"StartTime":74568.0,"EndTime":74568.0,"X":395.0,"Y":145.0}]},{"StartTime":74730.0,"Objects":[{"StartTime":74730.0,"EndTime":74730.0,"X":498.0,"Y":35.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":74856.0,"EndTime":74856.0,"X":506.846649,"Y":107.542564,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":75054.0,"Objects":[{"StartTime":75054.0,"EndTime":75054.0,"X":378.0,"Y":40.0}]},{"StartTime":75216.0,"Objects":[{"StartTime":75216.0,"EndTime":75216.0,"X":278.88028,"Y":141.88028}]},{"StartTime":75297.0,"Objects":[{"StartTime":75297.0,"EndTime":75297.0,"X":282.440155,"Y":145.44014}]},{"StartTime":75378.0,"Objects":[{"StartTime":75378.0,"EndTime":75378.0,"X":286.0,"Y":149.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":75504.0,"EndTime":75504.0,"X":300.0564,"Y":77.30375,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":75703.0,"Objects":[{"StartTime":75703.0,"EndTime":75703.0,"X":436.0,"Y":241.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":75829.0,"EndTime":75829.0,"X":367.113739,"Y":265.2332,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":76027.0,"Objects":[{"StartTime":76027.0,"EndTime":76027.0,"X":241.0,"Y":384.0}]},{"StartTime":76270.0,"Objects":[{"StartTime":76270.0,"EndTime":76270.0,"X":464.0,"Y":175.0}]},{"StartTime":76514.0,"Objects":[{"StartTime":76514.0,"EndTime":76514.0,"X":210.0,"Y":32.0}]},{"StartTime":76676.0,"Objects":[{"StartTime":76676.0,"EndTime":76676.0,"X":280.0,"Y":204.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":76964.0,"EndTime":76964.0,"X":235.7542,"Y":130.475433,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":77162.0,"Objects":[{"StartTime":77162.0,"EndTime":77162.0,"X":367.0,"Y":265.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77369.0,"EndTime":77369.0,"X":496.5096,"Y":318.670868,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":77487.0,"Objects":[{"StartTime":77487.0,"EndTime":77487.0,"X":501.88028,"Y":239.88028}]},{"StartTime":77568.0,"Objects":[{"StartTime":77568.0,"EndTime":77568.0,"X":505.440155,"Y":243.44014}]},{"StartTime":77649.0,"Objects":[{"StartTime":77649.0,"EndTime":77649.0,"X":509.0,"Y":247.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77856.0,"EndTime":77856.0,"X":488.1183,"Y":99.71859,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":77973.0,"Objects":[{"StartTime":77973.0,"EndTime":77973.0,"X":428.0,"Y":51.0}]},{"StartTime":78135.0,"Objects":[{"StartTime":78135.0,"EndTime":78135.0,"X":280.0,"Y":204.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":78342.0,"EndTime":78342.0,"X":297.461823,"Y":56.3390465,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":78460.0,"Objects":[{"StartTime":78460.0,"EndTime":78460.0,"X":353.0,"Y":6.0}]},{"StartTime":78622.0,"Objects":[{"StartTime":78622.0,"EndTime":78622.0,"X":175.0,"Y":118.0}]},{"StartTime":78784.0,"Objects":[{"StartTime":78784.0,"EndTime":78784.0,"X":367.0,"Y":265.0}]},{"StartTime":78946.0,"Objects":[{"StartTime":78946.0,"EndTime":78946.0,"X":301.0,"Y":135.0}]},{"StartTime":79108.0,"Objects":[{"StartTime":79108.0,"EndTime":79108.0,"X":131.88028,"Y":228.88028}]},{"StartTime":79189.0,"Objects":[{"StartTime":79189.0,"EndTime":79189.0,"X":135.44014,"Y":232.44014}]},{"StartTime":79270.0,"Objects":[{"StartTime":79270.0,"EndTime":79270.0,"X":139.0,"Y":236.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":79477.0,"EndTime":79477.0,"X":261.5925,"Y":303.983337,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":79595.0,"Objects":[{"StartTime":79595.0,"EndTime":79595.0,"X":330.0,"Y":335.0}]},{"StartTime":79757.0,"Objects":[{"StartTime":79757.0,"EndTime":79757.0,"X":231.0,"Y":178.0}]},{"StartTime":79919.0,"Objects":[{"StartTime":79919.0,"EndTime":79919.0,"X":391.0,"Y":241.0}]},{"StartTime":80081.0,"Objects":[{"StartTime":80081.0,"EndTime":80081.0,"X":228.0,"Y":375.0}]},{"StartTime":80243.0,"Objects":[{"StartTime":80243.0,"EndTime":80243.0,"X":311.0,"Y":114.0}]},{"StartTime":80405.0,"Objects":[{"StartTime":80405.0,"EndTime":80405.0,"X":442.0,"Y":339.0}]},{"StartTime":80568.0,"Objects":[{"StartTime":80568.0,"EndTime":80568.0,"X":386.0,"Y":52.0}]},{"StartTime":80730.0,"Objects":[{"StartTime":80730.0,"EndTime":80730.0,"X":327.0,"Y":359.0}]},{"StartTime":81541.0,"Objects":[{"StartTime":81541.0,"EndTime":81541.0,"X":62.0,"Y":133.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":81748.0,"EndTime":81748.0,"X":206.079437,"Y":76.30348,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":81865.0,"Objects":[{"StartTime":81865.0,"EndTime":81865.0,"X":256.0,"Y":133.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":81991.0,"EndTime":81991.0,"X":238.8628,"Y":204.038742,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":82189.0,"Objects":[{"StartTime":82189.0,"EndTime":82189.0,"X":399.0,"Y":329.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":82315.0,"EndTime":82315.0,"X":391.4031,"Y":256.37088,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":82514.0,"Objects":[{"StartTime":82514.0,"EndTime":82514.0,"X":203.0,"Y":313.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":82640.0,"EndTime":82640.0,"X":271.74295,"Y":337.270447,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":82838.0,"Objects":[{"StartTime":82838.0,"EndTime":82838.0,"X":406.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":82964.0,"EndTime":82964.0,"X":337.526459,"Y":192.858139,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":83162.0,"Objects":[{"StartTime":83162.0,"EndTime":83162.0,"X":209.0,"Y":56.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":83450.0,"EndTime":83450.0,"X":328.328674,"Y":92.72046,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":83649.0,"Objects":[{"StartTime":83649.0,"EndTime":83649.0,"X":239.0,"Y":204.0}]},{"StartTime":83811.0,"Objects":[{"StartTime":83811.0,"EndTime":83811.0,"X":328.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":83937.0,"EndTime":83937.0,"X":395.987061,"Y":65.98791,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":84135.0,"Objects":[{"StartTime":84135.0,"EndTime":84135.0,"X":507.0,"Y":193.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":84261.0,"EndTime":84261.0,"X":438.416,"Y":169.361435,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":84460.0,"Objects":[{"StartTime":84460.0,"EndTime":84460.0,"X":274.0,"Y":337.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":84748.0,"EndTime":84748.0,"X":385.897217,"Y":352.294159,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":84946.0,"Objects":[{"StartTime":84946.0,"EndTime":84946.0,"X":239.0,"Y":204.0}]},{"StartTime":85108.0,"Objects":[{"StartTime":85108.0,"EndTime":85108.0,"X":186.0,"Y":260.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":85234.0,"EndTime":85234.0,"X":115.1999,"Y":278.111664,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":85432.0,"Objects":[{"StartTime":85432.0,"EndTime":85432.0,"X":281.0,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":85558.0,"EndTime":85558.0,"X":352.092682,"Y":152.926834,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":85757.0,"Objects":[{"StartTime":85757.0,"EndTime":85757.0,"X":161.0,"Y":5.0}]},{"StartTime":85919.0,"Objects":[{"StartTime":85919.0,"EndTime":85919.0,"X":239.0,"Y":204.0}]},{"StartTime":86081.0,"Objects":[{"StartTime":86081.0,"EndTime":86081.0,"X":273.0,"Y":44.0}]},{"StartTime":86243.0,"Objects":[{"StartTime":86243.0,"EndTime":86243.0,"X":124.0,"Y":190.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86531.0,"EndTime":86531.0,"X":303.0346,"Y":255.0868,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":86730.0,"Objects":[{"StartTime":86730.0,"EndTime":86730.0,"X":161.0,"Y":5.0}]},{"StartTime":86811.0,"Objects":[{"StartTime":86811.0,"EndTime":86811.0,"X":138.0,"Y":37.0}]},{"StartTime":86892.0,"Objects":[{"StartTime":86892.0,"EndTime":86892.0,"X":147.0,"Y":70.0}]},{"StartTime":87054.0,"Objects":[{"StartTime":87054.0,"EndTime":87054.0,"X":352.0,"Y":152.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87342.0,"EndTime":87342.0,"X":403.287628,"Y":316.874359,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":87541.0,"Objects":[{"StartTime":87541.0,"EndTime":87541.0,"X":239.0,"Y":204.0}]},{"StartTime":87703.0,"Objects":[{"StartTime":87703.0,"EndTime":87703.0,"X":183.0,"Y":253.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87829.0,"EndTime":87829.0,"X":200.273987,"Y":350.475433,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88027.0,"Objects":[{"StartTime":88027.0,"EndTime":88027.0,"X":303.440155,"Y":252.44014}]},{"StartTime":88189.0,"Objects":[{"StartTime":88189.0,"EndTime":88189.0,"X":193.0,"Y":142.0}]},{"StartTime":88351.0,"Objects":[{"StartTime":88351.0,"EndTime":88351.0,"X":307.0,"Y":256.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":88639.0,"EndTime":88639.0,"X":126.771782,"Y":318.279968,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88838.0,"Objects":[{"StartTime":88838.0,"EndTime":88838.0,"X":323.0,"Y":165.0}]},{"StartTime":89000.0,"Objects":[{"StartTime":89000.0,"EndTime":89000.0,"X":171.0,"Y":32.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89126.0,"EndTime":89126.0,"X":258.739349,"Y":64.95778,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":89324.0,"Objects":[{"StartTime":89324.0,"EndTime":89324.0,"X":166.0,"Y":153.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89531.0,"EndTime":89531.0,"X":84.73328,"Y":172.269424,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":89649.0,"Objects":[{"StartTime":89649.0,"EndTime":89649.0,"X":54.0,"Y":183.0}]},{"StartTime":89811.0,"Objects":[{"StartTime":89811.0,"EndTime":89811.0,"X":282.0,"Y":51.0}]},{"StartTime":89973.0,"Objects":[{"StartTime":89973.0,"EndTime":89973.0,"X":153.0,"Y":20.0}]},{"StartTime":90135.0,"Objects":[{"StartTime":90135.0,"EndTime":90135.0,"X":339.0,"Y":174.0}]},{"StartTime":90297.0,"Objects":[{"StartTime":90297.0,"EndTime":90297.0,"X":126.0,"Y":318.0}]},{"StartTime":90460.0,"Objects":[{"StartTime":90460.0,"EndTime":90460.0,"X":238.0,"Y":125.0}]},{"StartTime":90541.0,"Objects":[{"StartTime":90541.0,"EndTime":90541.0,"X":242.0,"Y":141.0}]},{"StartTime":90622.0,"Objects":[{"StartTime":90622.0,"EndTime":90622.0,"X":244.0,"Y":158.0}]},{"StartTime":90784.0,"Objects":[{"StartTime":90784.0,"EndTime":90784.0,"X":338.0,"Y":281.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":91072.0,"EndTime":91072.0,"X":438.117,"Y":323.4276,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":91270.0,"Objects":[{"StartTime":91270.0,"EndTime":91270.0,"X":481.0,"Y":138.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":91396.0,"EndTime":91396.0,"X":427.0648,"Y":151.829544,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":91514.0,"Objects":[{"StartTime":91514.0,"EndTime":91514.0,"X":356.0,"Y":170.0}]},{"StartTime":91595.0,"Objects":[{"StartTime":91595.0,"EndTime":91595.0,"X":339.0,"Y":174.0}]},{"StartTime":91757.0,"Objects":[{"StartTime":91757.0,"EndTime":91757.0,"X":463.0,"Y":235.0}]},{"StartTime":91919.0,"Objects":[{"StartTime":91919.0,"EndTime":91919.0,"X":369.440155,"Y":91.44014,"StackOffset":{"X":-3.559845,"Y":-3.55986023}},{"StartTime":92126.0,"EndTime":92126.0,"X":385.6048,"Y":9.84262,"StackOffset":{"X":-3.559845,"Y":-3.55986023}}]},{"StartTime":92243.0,"Objects":[{"StartTime":92243.0,"EndTime":92243.0,"X":389.0,"Y":13.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":92531.0,"EndTime":92531.0,"X":270.5224,"Y":51.6697578,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":92892.0,"Objects":[{"StartTime":92892.0,"EndTime":92892.0,"X":126.0,"Y":207.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":93018.0,"EndTime":93018.0,"X":196.3784,"Y":187.659012,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":93216.0,"Objects":[{"StartTime":93216.0,"EndTime":93216.0,"X":75.0,"Y":40.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":93342.0,"EndTime":93342.0,"X":145.749725,"Y":57.7823524,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":93541.0,"Objects":[{"StartTime":93541.0,"EndTime":93541.0,"X":42.0,"Y":128.0}]},{"StartTime":93703.0,"Objects":[{"StartTime":93703.0,"EndTime":93703.0,"X":126.0,"Y":207.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":93991.0,"EndTime":93991.0,"X":102.604004,"Y":351.27533,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":94189.0,"Objects":[{"StartTime":94189.0,"EndTime":94189.0,"X":260.0,"Y":235.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":94315.0,"EndTime":94315.0,"X":271.253265,"Y":307.208374,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":94514.0,"Objects":[{"StartTime":94514.0,"EndTime":94514.0,"X":404.0,"Y":168.0}]},{"StartTime":94676.0,"Objects":[{"StartTime":94676.0,"EndTime":94676.0,"X":329.0,"Y":138.0}]},{"StartTime":94838.0,"Objects":[{"StartTime":94838.0,"EndTime":94838.0,"X":389.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":95126.0,"EndTime":95126.0,"X":512.510864,"Y":209.161926,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":95487.0,"Objects":[{"StartTime":95487.0,"EndTime":95487.0,"X":512.0,"Y":209.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":95613.0,"EndTime":95613.0,"X":443.248566,"Y":185.697754,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":95811.0,"Objects":[{"StartTime":95811.0,"EndTime":95811.0,"X":306.0,"Y":51.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":95937.0,"EndTime":95937.0,"X":375.1769,"Y":29.8420277,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":96135.0,"Objects":[{"StartTime":96135.0,"EndTime":96135.0,"X":265.0,"Y":196.0}]},{"StartTime":96297.0,"Objects":[{"StartTime":96297.0,"EndTime":96297.0,"X":169.0,"Y":81.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":96423.0,"EndTime":96423.0,"X":237.498428,"Y":105.515129,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":96622.0,"Objects":[{"StartTime":96622.0,"EndTime":96622.0,"X":106.0,"Y":191.0}]},{"StartTime":96784.0,"Objects":[{"StartTime":96784.0,"EndTime":96784.0,"X":220.0,"Y":333.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":96946.0,"EndTime":96946.0,"X":210.935547,"Y":260.484344,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":97072.0,"EndTime":97072.0,"X":220.0,"Y":333.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":97432.0,"Objects":[{"StartTime":97432.0,"EndTime":97432.0,"X":208.0,"Y":345.0}]},{"StartTime":97595.0,"Objects":[{"StartTime":97595.0,"EndTime":97595.0,"X":365.88028,"Y":250.88028}]},{"StartTime":97676.0,"Objects":[{"StartTime":97676.0,"EndTime":97676.0,"X":369.440155,"Y":254.44014}]},{"StartTime":97757.0,"Objects":[{"StartTime":97757.0,"EndTime":97757.0,"X":373.0,"Y":258.0}]},{"StartTime":97919.0,"Objects":[{"StartTime":97919.0,"EndTime":97919.0,"X":286.0,"Y":206.0}]},{"StartTime":98081.0,"Objects":[{"StartTime":98081.0,"EndTime":98081.0,"X":396.88028,"Y":337.88028}]},{"StartTime":98162.0,"Objects":[{"StartTime":98162.0,"EndTime":98162.0,"X":400.440155,"Y":341.440155}]},{"StartTime":98243.0,"Objects":[{"StartTime":98243.0,"EndTime":98243.0,"X":404.0,"Y":345.0}]},{"StartTime":98405.0,"Objects":[{"StartTime":98405.0,"EndTime":98405.0,"X":295.0,"Y":285.0}]},{"StartTime":98568.0,"Objects":[{"StartTime":98568.0,"EndTime":98568.0,"X":447.88028,"Y":183.88028}]},{"StartTime":98649.0,"Objects":[{"StartTime":98649.0,"EndTime":98649.0,"X":451.440155,"Y":187.44014}]},{"StartTime":98730.0,"Objects":[{"StartTime":98730.0,"EndTime":98730.0,"X":455.0,"Y":191.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":98856.0,"EndTime":98856.0,"X":444.882874,"Y":118.623688,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":98973.0,"Objects":[{"StartTime":98973.0,"EndTime":98973.0,"X":433.0,"Y":49.0}]},{"StartTime":99054.0,"Objects":[{"StartTime":99054.0,"EndTime":99054.0,"X":430.0,"Y":33.0}]},{"StartTime":99216.0,"Objects":[{"StartTime":99216.0,"EndTime":99216.0,"X":322.0,"Y":120.0}]},{"StartTime":99378.0,"Objects":[{"StartTime":99378.0,"EndTime":99378.0,"X":236.0,"Y":48.0}]},{"StartTime":99460.0,"Objects":[{"StartTime":99460.0,"EndTime":99460.0,"X":237.0,"Y":36.0}]},{"StartTime":99541.0,"Objects":[{"StartTime":99541.0,"EndTime":99541.0,"X":239.0,"Y":20.0}]},{"StartTime":99703.0,"Objects":[{"StartTime":99703.0,"EndTime":99703.0,"X":322.0,"Y":120.0}]},{"StartTime":99865.0,"Objects":[{"StartTime":99865.0,"EndTime":99865.0,"X":166.0,"Y":190.0}]},{"StartTime":99946.0,"Objects":[{"StartTime":99946.0,"EndTime":99946.0,"X":165.0,"Y":177.0}]},{"StartTime":100027.0,"Objects":[{"StartTime":100027.0,"EndTime":100027.0,"X":163.0,"Y":166.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":100315.0,"EndTime":100315.0,"X":311.452423,"Y":199.964645,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":100514.0,"Objects":[{"StartTime":100514.0,"EndTime":100514.0,"X":136.0,"Y":71.0}]},{"StartTime":100676.0,"Objects":[{"StartTime":100676.0,"EndTime":100676.0,"X":322.0,"Y":120.0}]},{"StartTime":100838.0,"Objects":[{"StartTime":100838.0,"EndTime":100838.0,"X":119.0,"Y":280.0}]},{"StartTime":101000.0,"Objects":[{"StartTime":101000.0,"EndTime":101000.0,"X":236.0,"Y":48.0}]},{"StartTime":101162.0,"Objects":[{"StartTime":101162.0,"EndTime":101162.0,"X":346.0,"Y":315.0}]},{"StartTime":101324.0,"Objects":[{"StartTime":101324.0,"EndTime":101324.0,"X":47.0,"Y":167.0}]},{"StartTime":101487.0,"Objects":[{"StartTime":101487.0,"EndTime":101487.0,"X":384.0,"Y":47.0}]},{"StartTime":102135.0,"Objects":[{"StartTime":102135.0,"EndTime":102135.0,"X":384.88028,"Y":45.8802834}]},{"StartTime":102216.0,"Objects":[{"StartTime":102216.0,"EndTime":102216.0,"X":388.440155,"Y":49.44014}]},{"StartTime":102297.0,"Objects":[{"StartTime":102297.0,"EndTime":102297.0,"X":392.0,"Y":53.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":102504.0,"EndTime":102504.0,"X":287.890961,"Y":117.336929,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":102622.0,"Objects":[{"StartTime":102622.0,"EndTime":102622.0,"X":229.0,"Y":189.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":102910.0,"EndTime":102910.0,"X":382.2425,"Y":229.177368,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":103108.0,"Objects":[{"StartTime":103108.0,"EndTime":103108.0,"X":438.0,"Y":175.0}]},{"StartTime":103270.0,"Objects":[{"StartTime":103270.0,"EndTime":103270.0,"X":285.0,"Y":287.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103396.0,"EndTime":103396.0,"X":204.35733,"Y":300.591461,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":103595.0,"Objects":[{"StartTime":103595.0,"EndTime":103595.0,"X":343.0,"Y":378.0}]},{"StartTime":103757.0,"Objects":[{"StartTime":103757.0,"EndTime":103757.0,"X":363.0,"Y":307.0}]},{"StartTime":103919.0,"Objects":[{"StartTime":103919.0,"EndTime":103919.0,"X":194.0,"Y":121.0}]},{"StartTime":104081.0,"Objects":[{"StartTime":104081.0,"EndTime":104081.0,"X":229.0,"Y":189.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104207.0,"EndTime":104207.0,"X":308.993835,"Y":205.998688,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":104405.0,"Objects":[{"StartTime":104405.0,"EndTime":104405.0,"X":146.0,"Y":366.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104612.0,"EndTime":104612.0,"X":122.078148,"Y":267.7327,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":104730.0,"Objects":[{"StartTime":104730.0,"EndTime":104730.0,"X":95.0,"Y":201.0}]},{"StartTime":104892.0,"Objects":[{"StartTime":104892.0,"EndTime":104892.0,"X":272.0,"Y":122.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":105099.0,"EndTime":105099.0,"X":284.801727,"Y":18.6999683,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":105216.0,"Objects":[{"StartTime":105216.0,"EndTime":105216.0,"X":214.0,"Y":1.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":105504.0,"EndTime":105504.0,"X":60.34272,"Y":42.0378342,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":105703.0,"Objects":[{"StartTime":105703.0,"EndTime":105703.0,"X":194.0,"Y":121.0}]},{"StartTime":105865.0,"Objects":[{"StartTime":105865.0,"EndTime":105865.0,"X":95.0,"Y":201.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":105991.0,"EndTime":105991.0,"X":108.977585,"Y":120.423355,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":106189.0,"Objects":[{"StartTime":106189.0,"EndTime":106189.0,"X":279.0,"Y":219.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":106396.0,"EndTime":106396.0,"X":293.2297,"Y":340.8419,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":106514.0,"Objects":[{"StartTime":106514.0,"EndTime":106514.0,"X":231.0,"Y":382.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":106640.0,"EndTime":106640.0,"X":205.732849,"Y":318.131958,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":106838.0,"Objects":[{"StartTime":106838.0,"EndTime":106838.0,"X":369.0,"Y":185.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":106964.0,"EndTime":106964.0,"X":447.2368,"Y":208.8112,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":107162.0,"Objects":[{"StartTime":107162.0,"EndTime":107162.0,"X":310.0,"Y":88.0}]},{"StartTime":107324.0,"Objects":[{"StartTime":107324.0,"EndTime":107324.0,"X":300.0,"Y":16.0}]},{"StartTime":107487.0,"Objects":[{"StartTime":107487.0,"EndTime":107487.0,"X":194.0,"Y":121.0}]},{"StartTime":107649.0,"Objects":[{"StartTime":107649.0,"EndTime":107649.0,"X":370.88028,"Y":187.88028}]},{"StartTime":107730.0,"Objects":[{"StartTime":107730.0,"EndTime":107730.0,"X":374.440155,"Y":191.44014}]},{"StartTime":107811.0,"Objects":[{"StartTime":107811.0,"EndTime":107811.0,"X":378.0,"Y":195.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":108099.0,"EndTime":108099.0,"X":228.641785,"Y":248.379822,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":108297.0,"Objects":[{"StartTime":108297.0,"EndTime":108297.0,"X":122.0,"Y":87.0}]},{"StartTime":108460.0,"Objects":[{"StartTime":108460.0,"EndTime":108460.0,"X":194.0,"Y":121.0}]},{"StartTime":108622.0,"Objects":[{"StartTime":108622.0,"EndTime":108622.0,"X":48.0,"Y":214.0}]},{"StartTime":108784.0,"Objects":[{"StartTime":108784.0,"EndTime":108784.0,"X":37.0,"Y":137.0}]},{"StartTime":108946.0,"Objects":[{"StartTime":108946.0,"EndTime":108946.0,"X":136.88028,"Y":200.88028}]},{"StartTime":109027.0,"Objects":[{"StartTime":109027.0,"EndTime":109027.0,"X":140.44014,"Y":204.44014}]},{"StartTime":109108.0,"Objects":[{"StartTime":109108.0,"EndTime":109108.0,"X":144.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":109315.0,"EndTime":109315.0,"X":124.652115,"Y":329.134583,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":109432.0,"Objects":[{"StartTime":109432.0,"EndTime":109432.0,"X":58.0,"Y":361.0}]},{"StartTime":109595.0,"Objects":[{"StartTime":109595.0,"EndTime":109595.0,"X":244.0,"Y":241.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":109883.0,"EndTime":109883.0,"X":372.25354,"Y":294.5455,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":110081.0,"Objects":[{"StartTime":110081.0,"EndTime":110081.0,"X":194.0,"Y":121.0}]},{"StartTime":110243.0,"Objects":[{"StartTime":110243.0,"EndTime":110243.0,"X":354.0,"Y":43.0}]},{"StartTime":110405.0,"Objects":[{"StartTime":110405.0,"EndTime":110405.0,"X":290.0,"Y":0.0}]},{"StartTime":110568.0,"Objects":[{"StartTime":110568.0,"EndTime":110568.0,"X":394.88028,"Y":124.88028}]},{"StartTime":110649.0,"Objects":[{"StartTime":110649.0,"EndTime":110649.0,"X":398.440155,"Y":128.44014}]},{"StartTime":110730.0,"Objects":[{"StartTime":110730.0,"EndTime":110730.0,"X":402.0,"Y":132.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":110937.0,"EndTime":110937.0,"X":321.508484,"Y":160.5931,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":111054.0,"Objects":[{"StartTime":111054.0,"EndTime":111054.0,"X":117.0,"Y":295.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111342.0,"EndTime":111342.0,"X":293.832336,"Y":321.662231,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":111541.0,"Objects":[{"StartTime":111541.0,"EndTime":111541.0,"X":101.88028,"Y":156.88028}]},{"StartTime":111622.0,"Objects":[{"StartTime":111622.0,"EndTime":111622.0,"X":105.44014,"Y":160.44014}]},{"StartTime":111703.0,"Objects":[{"StartTime":111703.0,"EndTime":111703.0,"X":109.0,"Y":164.0}]},{"StartTime":111865.0,"Objects":[{"StartTime":111865.0,"EndTime":111865.0,"X":279.0,"Y":95.0}]},{"StartTime":112027.0,"Objects":[{"StartTime":112027.0,"EndTime":112027.0,"X":99.0,"Y":38.0}]},{"StartTime":112189.0,"Objects":[{"StartTime":112189.0,"EndTime":112189.0,"X":216.0,"Y":244.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":112396.0,"EndTime":112396.0,"X":195.586136,"Y":83.47279,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":112514.0,"Objects":[{"StartTime":112514.0,"EndTime":112514.0,"X":245.0,"Y":28.0}]},{"StartTime":112676.0,"Objects":[{"StartTime":112676.0,"EndTime":112676.0,"X":23.0,"Y":186.0}]},{"StartTime":112838.0,"Objects":[{"StartTime":112838.0,"EndTime":112838.0,"X":179.0,"Y":352.0}]},{"StartTime":112919.0,"Objects":[{"StartTime":112919.0,"EndTime":112919.0,"X":159.0,"Y":363.0}]},{"StartTime":113000.0,"Objects":[{"StartTime":113000.0,"EndTime":113000.0,"X":138.0,"Y":355.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":113045.0,"EndTime":113045.0,"X":149.7844,"Y":302.363037,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":113162.0,"Objects":[{"StartTime":113162.0,"EndTime":113162.0,"X":331.0,"Y":171.0}]},{"StartTime":113324.0,"Objects":[{"StartTime":113324.0,"EndTime":113324.0,"X":397.0,"Y":215.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":113450.0,"EndTime":113450.0,"X":406.577026,"Y":269.8502,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":113649.0,"Objects":[{"StartTime":113649.0,"EndTime":113649.0,"X":252.0,"Y":149.0}]},{"StartTime":113811.0,"Objects":[{"StartTime":113811.0,"EndTime":113811.0,"X":411.0,"Y":51.0}]},{"StartTime":113973.0,"Objects":[{"StartTime":113973.0,"EndTime":113973.0,"X":347.0,"Y":16.0}]},{"StartTime":114135.0,"Objects":[{"StartTime":114135.0,"EndTime":114135.0,"X":457.0,"Y":161.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":114423.0,"EndTime":114423.0,"X":272.994629,"Y":240.572388,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":114622.0,"Objects":[{"StartTime":114622.0,"EndTime":114622.0,"X":126.0,"Y":120.0}]},{"StartTime":114784.0,"Objects":[{"StartTime":114784.0,"EndTime":114784.0,"X":267.0,"Y":55.0}]},{"StartTime":114946.0,"Objects":[{"StartTime":114946.0,"EndTime":114946.0,"X":130.0,"Y":257.0}]},{"StartTime":115108.0,"Objects":[{"StartTime":115108.0,"EndTime":115108.0,"X":171.88028,"Y":28.8802814}]},{"StartTime":115189.0,"Objects":[{"StartTime":115189.0,"EndTime":115189.0,"X":175.44014,"Y":32.44014}]},{"StartTime":115270.0,"Objects":[{"StartTime":115270.0,"EndTime":115270.0,"X":179.0,"Y":36.0}]},{"StartTime":115432.0,"Objects":[{"StartTime":115432.0,"EndTime":115432.0,"X":353.0,"Y":157.0}]},{"StartTime":115595.0,"Objects":[{"StartTime":115595.0,"EndTime":115595.0,"X":130.0,"Y":257.0}]},{"StartTime":115757.0,"Objects":[{"StartTime":115757.0,"EndTime":115757.0,"X":353.0,"Y":74.0}]},{"StartTime":115919.0,"Objects":[{"StartTime":115919.0,"EndTime":115919.0,"X":259.0,"Y":0.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":116045.0,"EndTime":116045.0,"X":267.296478,"Y":55.0584335,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":116162.0,"Objects":[{"StartTime":116162.0,"EndTime":116162.0,"X":280.0,"Y":124.0}]},{"StartTime":116243.0,"Objects":[{"StartTime":116243.0,"EndTime":116243.0,"X":283.0,"Y":135.0}]},{"StartTime":116405.0,"Objects":[{"StartTime":116405.0,"EndTime":116405.0,"X":441.0,"Y":224.0}]},{"StartTime":116568.0,"Objects":[{"StartTime":116568.0,"EndTime":116568.0,"X":353.0,"Y":74.0}]},{"StartTime":116730.0,"Objects":[{"StartTime":116730.0,"EndTime":116730.0,"X":203.0,"Y":217.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":117018.0,"EndTime":117018.0,"X":301.176636,"Y":253.817078,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":117216.0,"Objects":[{"StartTime":117216.0,"EndTime":117216.0,"X":180.0,"Y":111.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":117342.0,"EndTime":117342.0,"X":74.6470947,"Y":87.78665,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":117541.0,"Objects":[{"StartTime":117541.0,"EndTime":117541.0,"X":241.0,"Y":8.0}]},{"StartTime":117703.0,"Objects":[{"StartTime":117703.0,"EndTime":117703.0,"X":76.0,"Y":228.0}]},{"StartTime":117784.0,"Objects":[{"StartTime":117784.0,"EndTime":117784.0,"X":80.0,"Y":217.0}]},{"StartTime":117865.0,"Objects":[{"StartTime":117865.0,"EndTime":117865.0,"X":85.0,"Y":205.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":117910.0,"EndTime":117910.0,"X":136.95993,"Y":219.480637,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":118027.0,"Objects":[{"StartTime":118027.0,"EndTime":118027.0,"X":334.0,"Y":361.0}]},{"StartTime":118108.0,"Objects":[{"StartTime":118108.0,"EndTime":118108.0,"X":328.0,"Y":344.0}]},{"StartTime":118189.0,"Objects":[{"StartTime":118189.0,"EndTime":118189.0,"X":321.0,"Y":329.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":118315.0,"EndTime":118315.0,"X":249.125366,"Y":342.218323,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":118432.0,"Objects":[{"StartTime":118432.0,"EndTime":118432.0,"X":180.0,"Y":358.0}]},{"StartTime":118514.0,"Objects":[{"StartTime":118514.0,"EndTime":118514.0,"X":164.0,"Y":362.0}]},{"StartTime":118676.0,"Objects":[{"StartTime":118676.0,"EndTime":118676.0,"X":301.0,"Y":253.0}]},{"StartTime":118838.0,"Objects":[{"StartTime":118838.0,"EndTime":118838.0,"X":407.0,"Y":282.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":118964.0,"EndTime":118964.0,"X":478.0228,"Y":299.217651,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":119162.0,"Objects":[{"StartTime":119162.0,"EndTime":119162.0,"X":321.0,"Y":172.0}]},{"StartTime":119324.0,"Objects":[{"StartTime":119324.0,"EndTime":119324.0,"X":445.0,"Y":94.0}]},{"StartTime":119405.0,"Objects":[{"StartTime":119405.0,"EndTime":119405.0,"X":431.0,"Y":98.0}]},{"StartTime":119487.0,"Objects":[{"StartTime":119487.0,"EndTime":119487.0,"X":416.0,"Y":103.0}]},{"StartTime":119649.0,"Objects":[{"StartTime":119649.0,"EndTime":119649.0,"X":316.0,"Y":12.0}]},{"StartTime":119730.0,"Objects":[{"StartTime":119730.0,"EndTime":119730.0,"X":331.0,"Y":16.0}]},{"StartTime":119811.0,"Objects":[{"StartTime":119811.0,"EndTime":119811.0,"X":342.0,"Y":22.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":119937.0,"EndTime":119937.0,"X":325.503876,"Y":93.19385,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":120135.0,"Objects":[{"StartTime":120135.0,"EndTime":120135.0,"X":194.0,"Y":229.0}]},{"StartTime":120216.0,"Objects":[{"StartTime":120216.0,"EndTime":120216.0,"X":150.0,"Y":214.0}]},{"StartTime":120297.0,"Objects":[{"StartTime":120297.0,"EndTime":120297.0,"X":132.0,"Y":178.0}]},{"StartTime":120378.0,"Objects":[{"StartTime":120378.0,"EndTime":120378.0,"X":143.0,"Y":139.0}]},{"StartTime":120460.0,"Objects":[{"StartTime":120460.0,"EndTime":120460.0,"X":175.0,"Y":120.0}]},{"StartTime":120622.0,"Objects":[{"StartTime":120622.0,"EndTime":120622.0,"X":323.88028,"Y":265.88028}]},{"StartTime":120703.0,"Objects":[{"StartTime":120703.0,"EndTime":120703.0,"X":327.440155,"Y":269.440155}]},{"StartTime":120784.0,"Objects":[{"StartTime":120784.0,"EndTime":120784.0,"X":331.0,"Y":273.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":120910.0,"EndTime":120910.0,"X":260.102,"Y":290.724518,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":121027.0,"Objects":[{"StartTime":121027.0,"EndTime":121027.0,"X":189.0,"Y":306.0}]},{"StartTime":121108.0,"Objects":[{"StartTime":121108.0,"EndTime":121108.0,"X":172.0,"Y":311.0}]},{"StartTime":121270.0,"Objects":[{"StartTime":121270.0,"EndTime":121270.0,"X":278.0,"Y":193.0}]},{"StartTime":121432.0,"Objects":[{"StartTime":121432.0,"EndTime":121432.0,"X":175.0,"Y":120.0}]},{"StartTime":121514.0,"Objects":[{"StartTime":121514.0,"EndTime":121514.0,"X":174.0,"Y":109.0}]},{"StartTime":121595.0,"Objects":[{"StartTime":121595.0,"EndTime":121595.0,"X":173.0,"Y":98.0}]},{"StartTime":121757.0,"Objects":[{"StartTime":121757.0,"EndTime":121757.0,"X":276.0,"Y":0.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":121883.0,"EndTime":121883.0,"X":263.553436,"Y":72.01228,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":122000.0,"Objects":[{"StartTime":122000.0,"EndTime":122000.0,"X":311.440155,"Y":122.44014}]},{"StartTime":122081.0,"Objects":[{"StartTime":122081.0,"EndTime":122081.0,"X":315.0,"Y":126.0}]},{"StartTime":122243.0,"Objects":[{"StartTime":122243.0,"EndTime":122243.0,"X":177.0,"Y":210.0}]},{"StartTime":122324.0,"Objects":[{"StartTime":122324.0,"EndTime":122324.0,"X":162.0,"Y":214.0}]},{"StartTime":122405.0,"Objects":[{"StartTime":122405.0,"EndTime":122405.0,"X":150.0,"Y":217.0}]},{"StartTime":122568.0,"Objects":[{"StartTime":122568.0,"EndTime":122568.0,"X":260.0,"Y":290.0}]},{"StartTime":122730.0,"Objects":[{"StartTime":122730.0,"EndTime":122730.0,"X":312.0,"Y":208.0}]},{"StartTime":122811.0,"Objects":[{"StartTime":122811.0,"EndTime":122811.0,"X":346.0,"Y":188.0}]},{"StartTime":122892.0,"Objects":[{"StartTime":122892.0,"EndTime":122892.0,"X":384.0,"Y":199.0}]},{"StartTime":122973.0,"Objects":[{"StartTime":122973.0,"EndTime":122973.0,"X":406.0,"Y":233.0}]},{"StartTime":123054.0,"Objects":[{"StartTime":123054.0,"EndTime":123054.0,"X":391.0,"Y":250.0}]},{"StartTime":123135.0,"Objects":[{"StartTime":123135.0,"EndTime":123135.0,"X":413.0,"Y":304.0}]},{"StartTime":123216.0,"Objects":[{"StartTime":123216.0,"EndTime":123216.0,"X":474.0,"Y":312.0}]},{"StartTime":123297.0,"Objects":[{"StartTime":123297.0,"EndTime":123297.0,"X":512.0,"Y":266.0}]},{"StartTime":123378.0,"Objects":[{"StartTime":123378.0,"EndTime":123378.0,"X":512.0,"Y":251.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":123504.0,"EndTime":123504.0,"X":499.302948,"Y":161.415314,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":123703.0,"Objects":[{"StartTime":123703.0,"EndTime":123703.0,"X":260.0,"Y":290.0}]},{"StartTime":123865.0,"Objects":[{"StartTime":123865.0,"EndTime":123865.0,"X":324.0,"Y":73.0}]},{"StartTime":124027.0,"Objects":[{"StartTime":124027.0,"EndTime":124027.0,"X":413.0,"Y":304.0}]},{"StartTime":124189.0,"Objects":[{"StartTime":124189.0,"EndTime":124189.0,"X":222.0,"Y":147.0}]},{"StartTime":124351.0,"Objects":[{"StartTime":124351.0,"EndTime":124351.0,"X":437.0,"Y":36.0}]},{"StartTime":124514.0,"Objects":[{"StartTime":124514.0,"EndTime":124514.0,"X":346.0,"Y":188.0}]},{"StartTime":124676.0,"Objects":[{"StartTime":124676.0,"EndTime":124676.0,"X":192.0,"Y":21.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":124802.0,"EndTime":124802.0,"X":104.078423,"Y":-0.3641224,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":125000.0,"Objects":[{"StartTime":125000.0,"EndTime":125000.0,"X":222.0,"Y":147.0}]},{"StartTime":125162.0,"Objects":[{"StartTime":125162.0,"EndTime":125162.0,"X":22.0,"Y":215.0}]},{"StartTime":125324.0,"Objects":[{"StartTime":125324.0,"EndTime":125324.0,"X":107.55986,"Y":3.559859}]},{"StartTime":125487.0,"Objects":[{"StartTime":125487.0,"EndTime":125487.0,"X":240.0,"Y":244.0}]},{"StartTime":125568.0,"Objects":[{"StartTime":125568.0,"EndTime":125568.0,"X":238.0,"Y":231.0}]},{"StartTime":125649.0,"Objects":[{"StartTime":125649.0,"EndTime":125649.0,"X":235.0,"Y":218.0}]},{"StartTime":125811.0,"Objects":[{"StartTime":125811.0,"EndTime":125811.0,"X":59.0,"Y":133.0}]},{"StartTime":125892.0,"Objects":[{"StartTime":125892.0,"EndTime":125892.0,"X":62.0,"Y":122.0}]},{"StartTime":125973.0,"Objects":[{"StartTime":125973.0,"EndTime":125973.0,"X":64.0,"Y":111.0}]},{"StartTime":126135.0,"Objects":[{"StartTime":126135.0,"EndTime":126135.0,"X":22.0,"Y":215.0}]},{"StartTime":126297.0,"Objects":[{"StartTime":126297.0,"EndTime":126297.0,"X":157.0,"Y":96.0}]},{"StartTime":126460.0,"Objects":[{"StartTime":126460.0,"EndTime":126460.0,"X":104.0,"Y":270.0}]},{"StartTime":126622.0,"Objects":[{"StartTime":126622.0,"EndTime":126622.0,"X":241.0,"Y":72.0}]},{"StartTime":126784.0,"Objects":[{"StartTime":126784.0,"EndTime":126784.0,"X":198.0,"Y":320.0}]},{"StartTime":126946.0,"Objects":[{"StartTime":126946.0,"EndTime":126946.0,"X":330.0,"Y":46.0}]},{"StartTime":127108.0,"Objects":[{"StartTime":127108.0,"EndTime":127108.0,"X":294.0,"Y":371.0}]},{"StartTime":127270.0,"Objects":[{"StartTime":127270.0,"EndTime":127270.0,"X":436.0,"Y":24.0}]},{"StartTime":127432.0,"Objects":[{"StartTime":127432.0,"EndTime":127432.0,"X":128.0,"Y":184.0}]},{"StartTime":127595.0,"Objects":[{"StartTime":127595.0,"EndTime":127595.0,"X":446.0,"Y":344.0}]},{"StartTime":127757.0,"Objects":[{"StartTime":127757.0,"EndTime":127757.0,"X":262.440155,"Y":-3.559859}]},{"StartTime":127919.0,"Objects":[{"StartTime":127919.0,"EndTime":127919.0,"X":152.0,"Y":384.0}]},{"StartTime":128081.0,"Objects":[{"StartTime":128081.0,"EndTime":128081.0,"X":512.0,"Y":170.0}]},{"StartTime":128243.0,"Objects":[{"StartTime":128243.0,"EndTime":128243.0,"X":266.0,"Y":0.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":128369.0,"EndTime":128369.0,"X":278.288239,"Y":133.415283,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":128487.0,"Objects":[{"StartTime":128487.0,"EndTime":128487.0,"X":378.0,"Y":196.0}]},{"StartTime":128568.0,"Objects":[{"StartTime":128568.0,"EndTime":128568.0,"X":396.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":128856.0,"EndTime":128856.0,"X":218.724335,"Y":206.941788,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":129054.0,"Objects":[{"StartTime":129054.0,"EndTime":129054.0,"X":365.0,"Y":56.0}]},{"StartTime":129216.0,"Objects":[{"StartTime":129216.0,"EndTime":129216.0,"X":312.0,"Y":299.0}]},{"StartTime":129378.0,"Objects":[{"StartTime":129378.0,"EndTime":129378.0,"X":196.0,"Y":119.0}]},{"StartTime":129541.0,"Objects":[{"StartTime":129541.0,"EndTime":129541.0,"X":396.0,"Y":176.0}]},{"StartTime":129703.0,"Objects":[{"StartTime":129703.0,"EndTime":129703.0,"X":208.0,"Y":302.0}]},{"StartTime":129865.0,"Objects":[{"StartTime":129865.0,"EndTime":129865.0,"X":298.0,"Y":190.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130153.0,"EndTime":130153.0,"X":491.098358,"Y":263.395782,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130351.0,"Objects":[{"StartTime":130351.0,"EndTime":130351.0,"X":281.0,"Y":90.0}]},{"StartTime":130514.0,"Objects":[{"StartTime":130514.0,"EndTime":130514.0,"X":484.0,"Y":174.0}]},{"StartTime":130676.0,"Objects":[{"StartTime":130676.0,"EndTime":130676.0,"X":312.0,"Y":299.0}]},{"StartTime":130838.0,"Objects":[{"StartTime":130838.0,"EndTime":130838.0,"X":504.0,"Y":380.0}]},{"StartTime":131000.0,"Objects":[{"StartTime":131000.0,"EndTime":131000.0,"X":299.88028,"Y":190.88028}]},{"StartTime":131081.0,"Objects":[{"StartTime":131081.0,"EndTime":131081.0,"X":303.440155,"Y":194.44014}]},{"StartTime":131162.0,"Objects":[{"StartTime":131162.0,"EndTime":131162.0,"X":307.0,"Y":198.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":131369.0,"EndTime":131369.0,"X":166.610275,"Y":253.783371,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":131487.0,"Objects":[{"StartTime":131487.0,"EndTime":131487.0,"X":110.0,"Y":300.0}]},{"StartTime":131649.0,"Objects":[{"StartTime":131649.0,"EndTime":131649.0,"X":0.0,"Y":176.0}]},{"StartTime":131811.0,"Objects":[{"StartTime":131811.0,"EndTime":131811.0,"X":144.0,"Y":107.0}]},{"StartTime":131973.0,"Objects":[{"StartTime":131973.0,"EndTime":131973.0,"X":3.0,"Y":282.0}]},{"StartTime":132135.0,"Objects":[{"StartTime":132135.0,"EndTime":132135.0,"X":208.44014,"Y":182.44014}]},{"StartTime":132297.0,"Objects":[{"StartTime":132297.0,"EndTime":132297.0,"X":77.0,"Y":31.0}]},{"StartTime":132460.0,"Objects":[{"StartTime":132460.0,"EndTime":132460.0,"X":212.0,"Y":186.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":132586.0,"EndTime":132586.0,"X":307.203156,"Y":207.722458,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":132784.0,"Objects":[{"StartTime":132784.0,"EndTime":132784.0,"X":455.0,"Y":384.0}]},{"StartTime":132946.0,"Objects":[{"StartTime":132946.0,"EndTime":132946.0,"X":297.0,"Y":290.0}]},{"StartTime":133108.0,"Objects":[{"StartTime":133108.0,"EndTime":133108.0,"X":430.0,"Y":145.0}]},{"StartTime":133270.0,"Objects":[{"StartTime":133270.0,"EndTime":133270.0,"X":339.0,"Y":362.0}]},{"StartTime":133432.0,"Objects":[{"StartTime":133432.0,"EndTime":133432.0,"X":512.0,"Y":239.0}]},{"StartTime":133595.0,"Objects":[{"StartTime":133595.0,"EndTime":133595.0,"X":336.88028,"Y":84.88028}]},{"StartTime":133676.0,"Objects":[{"StartTime":133676.0,"EndTime":133676.0,"X":340.440155,"Y":88.44014}]},{"StartTime":133757.0,"Objects":[{"StartTime":133757.0,"EndTime":133757.0,"X":344.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":133964.0,"EndTime":133964.0,"X":366.340729,"Y":252.2704,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":134081.0,"Objects":[{"StartTime":134081.0,"EndTime":134081.0,"X":297.0,"Y":290.0}]},{"StartTime":134243.0,"Objects":[{"StartTime":134243.0,"EndTime":134243.0,"X":430.0,"Y":145.0}]},{"StartTime":134405.0,"Objects":[{"StartTime":134405.0,"EndTime":134405.0,"X":277.0,"Y":8.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":134531.0,"EndTime":134531.0,"X":249.166824,"Y":99.0191956,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":134730.0,"Objects":[{"StartTime":134730.0,"EndTime":134730.0,"X":442.0,"Y":257.0}]},{"StartTime":134892.0,"Objects":[{"StartTime":134892.0,"EndTime":134892.0,"X":344.0,"Y":92.0}]},{"StartTime":135054.0,"Objects":[{"StartTime":135054.0,"EndTime":135054.0,"X":205.0,"Y":225.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":135180.0,"EndTime":135180.0,"X":100.106705,"Y":249.039276,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":135378.0,"Objects":[{"StartTime":135378.0,"EndTime":135378.0,"X":268.0,"Y":363.0}]},{"StartTime":135541.0,"Objects":[{"StartTime":135541.0,"EndTime":135541.0,"X":91.0,"Y":241.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":135829.0,"EndTime":135829.0,"X":170.039261,"Y":105.009521,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":136027.0,"Objects":[{"StartTime":136027.0,"EndTime":136027.0,"X":331.0,"Y":242.0}]},{"StartTime":136189.0,"Objects":[{"StartTime":136189.0,"EndTime":136189.0,"X":128.0,"Y":372.0}]},{"StartTime":136351.0,"Objects":[{"StartTime":136351.0,"EndTime":136351.0,"X":223.0,"Y":299.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":136558.0,"EndTime":136558.0,"X":360.467651,"Y":366.594482,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":136676.0,"Objects":[{"StartTime":136676.0,"EndTime":136676.0,"X":433.0,"Y":383.0}]},{"StartTime":136838.0,"Objects":[{"StartTime":136838.0,"EndTime":136838.0,"X":257.0,"Y":167.0}]},{"StartTime":137000.0,"Objects":[{"StartTime":137000.0,"EndTime":137000.0,"X":491.0,"Y":120.0}]},{"StartTime":137162.0,"Objects":[{"StartTime":137162.0,"EndTime":137162.0,"X":330.88028,"Y":240.88028}]},{"StartTime":137243.0,"Objects":[{"StartTime":137243.0,"EndTime":137243.0,"X":334.440155,"Y":244.44014}]},{"StartTime":137324.0,"Objects":[{"StartTime":137324.0,"EndTime":137324.0,"X":338.0,"Y":248.0}]},{"StartTime":137487.0,"Objects":[{"StartTime":137487.0,"EndTime":137487.0,"X":198.0,"Y":103.0}]},{"StartTime":137649.0,"Objects":[{"StartTime":137649.0,"EndTime":137649.0,"X":435.0,"Y":212.0}]},{"StartTime":137811.0,"Objects":[{"StartTime":137811.0,"EndTime":137811.0,"X":208.0,"Y":351.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":137856.0,"EndTime":137856.0,"X":222.192551,"Y":298.960632,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":137973.0,"Objects":[{"StartTime":137973.0,"EndTime":137973.0,"X":69.0,"Y":173.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":138054.0,"EndTime":138054.0,"X":79.83893,"Y":225.839767,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":138099.0,"EndTime":138099.0,"X":69.0,"Y":173.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":138297.0,"Objects":[{"StartTime":138297.0,"EndTime":138297.0,"X":208.0,"Y":50.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":138342.0,"EndTime":138342.0,"X":197.973,"Y":102.999832,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":138460.0,"Objects":[{"StartTime":138460.0,"EndTime":138460.0,"X":46.0,"Y":24.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":138505.0,"EndTime":138505.0,"X":95.8045349,"Y":3.86346078,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":138622.0,"Objects":[{"StartTime":138622.0,"EndTime":138622.0,"X":256.440155,"Y":201.44014,"StackOffset":{"X":-3.559845,"Y":-3.55986023}},{"StartTime":138667.0,"EndTime":138667.0,"X":206.76033,"Y":180.891571,"StackOffset":{"X":-3.559845,"Y":-3.55986023}}]},{"StartTime":138784.0,"Objects":[{"StartTime":138784.0,"EndTime":138784.0,"X":343.0,"Y":15.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":138829.0,"EndTime":138829.0,"X":355.401733,"Y":65.85758,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":138946.0,"Objects":[{"StartTime":138946.0,"EndTime":138946.0,"X":210.0,"Y":184.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":139234.0,"EndTime":139234.0,"X":362.085327,"Y":234.37854,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":139432.0,"Objects":[{"StartTime":139432.0,"EndTime":139432.0,"X":512.0,"Y":353.0}]},{"StartTime":139595.0,"Objects":[{"StartTime":139595.0,"EndTime":139595.0,"X":435.0,"Y":378.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":139721.0,"EndTime":139721.0,"X":455.4719,"Y":272.080231,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":139919.0,"Objects":[{"StartTime":139919.0,"EndTime":139919.0,"X":274.0,"Y":125.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":140126.0,"EndTime":140126.0,"X":280.4883,"Y":280.441528,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":140243.0,"Objects":[{"StartTime":140243.0,"EndTime":140243.0,"X":289.0,"Y":361.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":140531.0,"EndTime":140531.0,"X":208.826126,"Y":293.6522,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":140730.0,"Objects":[{"StartTime":140730.0,"EndTime":140730.0,"X":387.0,"Y":151.0}]},{"StartTime":140892.0,"Objects":[{"StartTime":140892.0,"EndTime":140892.0,"X":187.0,"Y":95.0}]},{"StartTime":141054.0,"Objects":[{"StartTime":141054.0,"EndTime":141054.0,"X":368.0,"Y":321.0}]},{"StartTime":141216.0,"Objects":[{"StartTime":141216.0,"EndTime":141216.0,"X":287.0,"Y":41.0}]},{"StartTime":141378.0,"Objects":[{"StartTime":141378.0,"EndTime":141378.0,"X":101.0,"Y":196.0}]},{"StartTime":141541.0,"Objects":[{"StartTime":141541.0,"EndTime":141541.0,"X":308.0,"Y":124.0}]},{"StartTime":141703.0,"Objects":[{"StartTime":141703.0,"EndTime":141703.0,"X":107.0,"Y":7.0}]},{"StartTime":141865.0,"Objects":[{"StartTime":141865.0,"EndTime":141865.0,"X":226.0,"Y":219.0}]},{"StartTime":142027.0,"Objects":[{"StartTime":142027.0,"EndTime":142027.0,"X":374.0,"Y":40.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":142315.0,"EndTime":142315.0,"X":508.23288,"Y":152.034592,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":142514.0,"Objects":[{"StartTime":142514.0,"EndTime":142514.0,"X":287.0,"Y":41.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":142640.0,"EndTime":142640.0,"X":181.518585,"Y":18.5109634,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":142838.0,"Objects":[{"StartTime":142838.0,"EndTime":142838.0,"X":29.0,"Y":243.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":143045.0,"EndTime":143045.0,"X":167.568069,"Y":175.772644,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":143162.0,"Objects":[{"StartTime":143162.0,"EndTime":143162.0,"X":226.0,"Y":219.0}]},{"StartTime":143324.0,"Objects":[{"StartTime":143324.0,"EndTime":143324.0,"X":356.88028,"Y":340.88028}]},{"StartTime":143405.0,"Objects":[{"StartTime":143405.0,"EndTime":143405.0,"X":360.440155,"Y":344.440155}]},{"StartTime":143487.0,"Objects":[{"StartTime":143487.0,"EndTime":143487.0,"X":364.0,"Y":348.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":143532.0,"EndTime":143532.0,"X":311.31073,"Y":359.54834,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":143649.0,"Objects":[{"StartTime":143649.0,"EndTime":143649.0,"X":429.0,"Y":222.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":143694.0,"EndTime":143694.0,"X":480.925385,"Y":236.604019,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":143811.0,"Objects":[{"StartTime":143811.0,"EndTime":143811.0,"X":344.0,"Y":142.0}]},{"StartTime":143892.0,"Objects":[{"StartTime":143892.0,"EndTime":143892.0,"X":313.0,"Y":113.0}]},{"StartTime":143973.0,"Objects":[{"StartTime":143973.0,"EndTime":143973.0,"X":311.0,"Y":72.0}]},{"StartTime":144054.0,"Objects":[{"StartTime":144054.0,"EndTime":144054.0,"X":342.0,"Y":40.0}]},{"StartTime":144135.0,"Objects":[{"StartTime":144135.0,"EndTime":144135.0,"X":403.0,"Y":74.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":144423.0,"EndTime":144423.0,"X":204.603455,"Y":131.8075,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":144622.0,"Objects":[{"StartTime":144622.0,"EndTime":144622.0,"X":365.0,"Y":267.0}]},{"StartTime":144784.0,"Objects":[{"StartTime":144784.0,"EndTime":144784.0,"X":232.0,"Y":48.0}]},{"StartTime":144946.0,"Objects":[{"StartTime":144946.0,"EndTime":144946.0,"X":106.0,"Y":218.0}]},{"StartTime":145108.0,"Objects":[{"StartTime":145108.0,"EndTime":145108.0,"X":313.0,"Y":113.0}]},{"StartTime":145270.0,"Objects":[{"StartTime":145270.0,"EndTime":145270.0,"X":99.88028,"Y":-7.119718}]},{"StartTime":145351.0,"Objects":[{"StartTime":145351.0,"EndTime":145351.0,"X":103.44014,"Y":-3.559859}]},{"StartTime":145432.0,"Objects":[{"StartTime":145432.0,"EndTime":145432.0,"X":107.0,"Y":0.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":145558.0,"EndTime":145558.0,"X":122.551392,"Y":106.59304,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":145757.0,"Objects":[{"StartTime":145757.0,"EndTime":145757.0,"X":325.0,"Y":27.0}]},{"StartTime":145919.0,"Objects":[{"StartTime":145919.0,"EndTime":145919.0,"X":256.0,"Y":238.0}]},{"StartTime":146081.0,"Objects":[{"StartTime":146081.0,"EndTime":146081.0,"X":149.0,"Y":107.0}]},{"StartTime":146243.0,"Objects":[{"StartTime":146243.0,"EndTime":146243.0,"X":368.0,"Y":228.0}]},{"StartTime":146405.0,"Objects":[{"StartTime":146405.0,"EndTime":146405.0,"X":120.0,"Y":384.0}]},{"StartTime":146568.0,"Objects":[{"StartTime":146568.0,"EndTime":146568.0,"X":329.0,"Y":316.0}]},{"StartTime":146730.0,"Objects":[{"StartTime":146730.0,"EndTime":146730.0,"X":149.0,"Y":107.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":147018.0,"EndTime":147018.0,"X":166.077515,"Y":252.1589,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":147216.0,"Objects":[{"StartTime":147216.0,"EndTime":147216.0,"X":113.0,"Y":306.0}]},{"StartTime":147378.0,"Objects":[{"StartTime":147378.0,"EndTime":147378.0,"X":318.0,"Y":147.0}]},{"StartTime":147541.0,"Objects":[{"StartTime":147541.0,"EndTime":147541.0,"X":145.44014,"Y":67.44014}]},{"StartTime":147703.0,"Objects":[{"StartTime":147703.0,"EndTime":147703.0,"X":149.0,"Y":71.0}]},{"StartTime":147865.0,"Objects":[{"StartTime":147865.0,"EndTime":147865.0,"X":246.0,"Y":15.0}]},{"StartTime":148027.0,"Objects":[{"StartTime":148027.0,"EndTime":148027.0,"X":199.0,"Y":171.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":148153.0,"EndTime":148153.0,"X":128.68573,"Y":190.439545,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":148351.0,"Objects":[{"StartTime":148351.0,"EndTime":148351.0,"X":256.0,"Y":101.0}]},{"StartTime":148514.0,"Objects":[{"StartTime":148514.0,"EndTime":148514.0,"X":356.0,"Y":225.0}]},{"StartTime":148676.0,"Objects":[{"StartTime":148676.0,"EndTime":148676.0,"X":424.0,"Y":185.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":148802.0,"EndTime":148802.0,"X":436.841553,"Y":113.358727,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":149000.0,"Objects":[{"StartTime":149000.0,"EndTime":149000.0,"X":270.0,"Y":245.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":149126.0,"EndTime":149126.0,"X":283.376617,"Y":316.208832,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":149324.0,"Objects":[{"StartTime":149324.0,"EndTime":149324.0,"X":360.0,"Y":339.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":149612.0,"EndTime":149612.0,"X":221.613678,"Y":376.0835,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":149811.0,"Objects":[{"StartTime":149811.0,"EndTime":149811.0,"X":356.0,"Y":225.0}]},{"StartTime":149973.0,"Objects":[{"StartTime":149973.0,"EndTime":149973.0,"X":168.0,"Y":96.0}]},{"StartTime":150216.0,"Objects":[{"StartTime":150216.0,"EndTime":150216.0,"X":360.0,"Y":339.0}]},{"StartTime":150460.0,"Objects":[{"StartTime":150460.0,"EndTime":150460.0,"X":242.0,"Y":25.0}]},{"StartTime":150622.0,"Objects":[{"StartTime":150622.0,"EndTime":150622.0,"X":86.0,"Y":185.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":150784.0,"EndTime":150784.0,"X":22.42053,"Y":160.525543,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":150910.0,"EndTime":150910.0,"X":86.0,"Y":185.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":151108.0,"Objects":[{"StartTime":151108.0,"EndTime":151108.0,"X":140.0,"Y":240.0}]},{"StartTime":151270.0,"Objects":[{"StartTime":151270.0,"EndTime":151270.0,"X":256.0,"Y":131.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":151396.0,"EndTime":151396.0,"X":327.1842,"Y":114.462257,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":151514.0,"Objects":[{"StartTime":151514.0,"EndTime":151514.0,"X":399.0,"Y":95.0}]},{"StartTime":151595.0,"Objects":[{"StartTime":151595.0,"EndTime":151595.0,"X":419.0,"Y":89.0}]},{"StartTime":151757.0,"Objects":[{"StartTime":151757.0,"EndTime":151757.0,"X":319.0,"Y":36.0}]},{"StartTime":151919.0,"Objects":[{"StartTime":151919.0,"EndTime":151919.0,"X":416.0,"Y":229.0}]},{"StartTime":152081.0,"Objects":[{"StartTime":152081.0,"EndTime":152081.0,"X":489.0,"Y":32.0}]},{"StartTime":152243.0,"Objects":[{"StartTime":152243.0,"EndTime":152243.0,"X":327.0,"Y":114.0}]},{"StartTime":152568.0,"Objects":[{"StartTime":152568.0,"EndTime":152568.0,"X":331.0,"Y":126.0}]},{"StartTime":152892.0,"Objects":[{"StartTime":152892.0,"EndTime":152892.0,"X":335.0,"Y":138.0}]},{"StartTime":153216.0,"Objects":[{"StartTime":153216.0,"EndTime":153216.0,"X":488.0,"Y":256.0}]},{"StartTime":153541.0,"Objects":[{"StartTime":153541.0,"EndTime":153541.0,"X":488.0,"Y":244.0}]},{"StartTime":153865.0,"Objects":[{"StartTime":153865.0,"EndTime":153865.0,"X":489.0,"Y":228.0}]},{"StartTime":154027.0,"Objects":[{"StartTime":154027.0,"EndTime":154027.0,"X":315.0,"Y":330.0}]},{"StartTime":154189.0,"Objects":[{"StartTime":154189.0,"EndTime":154189.0,"X":426.0,"Y":384.0}]},{"StartTime":154351.0,"Objects":[{"StartTime":154351.0,"EndTime":154351.0,"X":328.88028,"Y":225.88028}]},{"StartTime":154432.0,"Objects":[{"StartTime":154432.0,"EndTime":154432.0,"X":332.440155,"Y":229.44014}]},{"StartTime":154514.0,"Objects":[{"StartTime":154514.0,"EndTime":154514.0,"X":336.0,"Y":233.0}]},{"StartTime":154676.0,"Objects":[{"StartTime":154676.0,"EndTime":154676.0,"X":137.0,"Y":361.0}]},{"StartTime":154838.0,"Objects":[{"StartTime":154838.0,"EndTime":154838.0,"X":202.0,"Y":170.0}]},{"StartTime":155162.0,"Objects":[{"StartTime":155162.0,"EndTime":155162.0,"X":205.0,"Y":190.0}]},{"StartTime":155487.0,"Objects":[{"StartTime":155487.0,"EndTime":155487.0,"X":208.0,"Y":209.0}]},{"StartTime":155811.0,"Objects":[{"StartTime":155811.0,"EndTime":155811.0,"X":80.0,"Y":122.0}]},{"StartTime":155973.0,"Objects":[{"StartTime":155973.0,"EndTime":155973.0,"X":230.0,"Y":48.0}]},{"StartTime":156135.0,"Objects":[{"StartTime":156135.0,"EndTime":156135.0,"X":61.0,"Y":0.0}]},{"StartTime":156297.0,"Objects":[{"StartTime":156297.0,"EndTime":156297.0,"X":193.0,"Y":148.0}]},{"StartTime":156378.0,"Objects":[{"StartTime":156378.0,"EndTime":156378.0,"X":217.0,"Y":158.0}]},{"StartTime":156460.0,"Objects":[{"StartTime":156460.0,"EndTime":156460.0,"X":244.0,"Y":152.0}]},{"StartTime":156622.0,"Objects":[{"StartTime":156622.0,"EndTime":156622.0,"X":120.0,"Y":246.0}]},{"StartTime":156784.0,"Objects":[{"StartTime":156784.0,"EndTime":156784.0,"X":294.0,"Y":99.0}]},{"StartTime":156865.0,"Objects":[{"StartTime":156865.0,"EndTime":156865.0,"X":318.0,"Y":82.0}]},{"StartTime":156946.0,"Objects":[{"StartTime":156946.0,"EndTime":156946.0,"X":351.0,"Y":87.0}]},{"StartTime":157108.0,"Objects":[{"StartTime":157108.0,"EndTime":157108.0,"X":428.0,"Y":207.0}]},{"StartTime":157270.0,"Objects":[{"StartTime":157270.0,"EndTime":157270.0,"X":230.0,"Y":48.0}]},{"StartTime":157432.0,"Objects":[{"StartTime":157432.0,"EndTime":157432.0,"X":120.0,"Y":246.0}]},{"StartTime":157757.0,"Objects":[{"StartTime":157757.0,"EndTime":157757.0,"X":122.0,"Y":229.0}]},{"StartTime":158081.0,"Objects":[{"StartTime":158081.0,"EndTime":158081.0,"X":124.0,"Y":213.0}]},{"StartTime":158243.0,"Objects":[{"StartTime":158243.0,"EndTime":158243.0,"X":295.0,"Y":314.0}]},{"StartTime":158405.0,"Objects":[{"StartTime":158405.0,"EndTime":158405.0,"X":122.0,"Y":384.0}]},{"StartTime":158568.0,"Objects":[{"StartTime":158568.0,"EndTime":158568.0,"X":324.0,"Y":222.0}]},{"StartTime":158730.0,"Objects":[{"StartTime":158730.0,"EndTime":158730.0,"X":428.0,"Y":368.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":158856.0,"EndTime":158856.0,"X":358.3122,"Y":345.993317,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":159054.0,"Objects":[{"StartTime":159054.0,"EndTime":159054.0,"X":380.0,"Y":162.0}]},{"StartTime":159216.0,"Objects":[{"StartTime":159216.0,"EndTime":159216.0,"X":242.0,"Y":215.0}]},{"StartTime":159378.0,"Objects":[{"StartTime":159378.0,"EndTime":159378.0,"X":428.0,"Y":368.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":159585.0,"EndTime":159585.0,"X":447.453766,"Y":260.12,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":159703.0,"Objects":[{"StartTime":159703.0,"EndTime":159703.0,"X":380.0,"Y":162.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":159910.0,"EndTime":159910.0,"X":426.5376,"Y":66.61574,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":160027.0,"Objects":[{"StartTime":160027.0,"EndTime":160027.0,"X":484.0,"Y":116.0}]},{"StartTime":160189.0,"Objects":[{"StartTime":160189.0,"EndTime":160189.0,"X":242.0,"Y":215.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":160477.0,"EndTime":160477.0,"X":143.721771,"Y":108.139114,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":160676.0,"Objects":[{"StartTime":160676.0,"EndTime":160676.0,"X":327.0,"Y":235.0}]},{"StartTime":160838.0,"Objects":[{"StartTime":160838.0,"EndTime":160838.0,"X":167.88028,"Y":369.88028}]},{"StartTime":160919.0,"Objects":[{"StartTime":160919.0,"EndTime":160919.0,"X":171.44014,"Y":373.440155}]},{"StartTime":161000.0,"Objects":[{"StartTime":161000.0,"EndTime":161000.0,"X":175.0,"Y":377.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":161126.0,"EndTime":161126.0,"X":150.120163,"Y":290.917664,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":161324.0,"Objects":[{"StartTime":161324.0,"EndTime":161324.0,"X":330.0,"Y":156.0}]},{"StartTime":161487.0,"Objects":[{"StartTime":161487.0,"EndTime":161487.0,"X":471.0,"Y":341.0}]},{"StartTime":161568.0,"Objects":[{"StartTime":161568.0,"EndTime":161568.0,"X":434.0,"Y":364.0}]},{"StartTime":161649.0,"Objects":[{"StartTime":161649.0,"EndTime":161649.0,"X":399.0,"Y":355.0}]},{"StartTime":161811.0,"Objects":[{"StartTime":161811.0,"EndTime":161811.0,"X":328.0,"Y":258.0}]},{"StartTime":161973.0,"Objects":[{"StartTime":161973.0,"EndTime":161973.0,"X":474.0,"Y":149.0}]},{"StartTime":162054.0,"Objects":[{"StartTime":162054.0,"EndTime":162054.0,"X":466.0,"Y":186.0}]},{"StartTime":162135.0,"Objects":[{"StartTime":162135.0,"EndTime":162135.0,"X":458.0,"Y":219.0}]},{"StartTime":162297.0,"Objects":[{"StartTime":162297.0,"EndTime":162297.0,"X":363.0,"Y":82.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":162585.0,"EndTime":162585.0,"X":195.744888,"Y":149.32608,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":162784.0,"Objects":[{"StartTime":162784.0,"EndTime":162784.0,"X":139.0,"Y":96.0}]},{"StartTime":162946.0,"Objects":[{"StartTime":162946.0,"EndTime":162946.0,"X":35.0,"Y":225.0}]},{"StartTime":163108.0,"Objects":[{"StartTime":163108.0,"EndTime":163108.0,"X":60.0,"Y":152.0}]},{"StartTime":163270.0,"Objects":[{"StartTime":163270.0,"EndTime":163270.0,"X":223.0,"Y":298.0}]},{"StartTime":163432.0,"Objects":[{"StartTime":163432.0,"EndTime":163432.0,"X":210.0,"Y":223.0}]},{"StartTime":163595.0,"Objects":[{"StartTime":163595.0,"EndTime":163595.0,"X":45.0,"Y":321.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":163721.0,"EndTime":163721.0,"X":137.6757,"Y":355.2489,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":163919.0,"Objects":[{"StartTime":163919.0,"EndTime":163919.0,"X":283.440155,"Y":242.44014}]},{"StartTime":164081.0,"Objects":[{"StartTime":164081.0,"EndTime":164081.0,"X":139.0,"Y":96.0}]},{"StartTime":164243.0,"Objects":[{"StartTime":164243.0,"EndTime":164243.0,"X":287.0,"Y":246.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":164369.0,"EndTime":164369.0,"X":381.60495,"Y":218.029053,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":164568.0,"Objects":[{"StartTime":164568.0,"EndTime":164568.0,"X":228.0,"Y":64.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":164775.0,"EndTime":164775.0,"X":178.179764,"Y":177.656937,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":164892.0,"Objects":[{"StartTime":164892.0,"EndTime":164892.0,"X":187.0,"Y":251.0}]},{"StartTime":165054.0,"Objects":[{"StartTime":165054.0,"EndTime":165054.0,"X":362.0,"Y":95.0}]},{"StartTime":165216.0,"Objects":[{"StartTime":165216.0,"EndTime":165216.0,"X":181.55986,"Y":180.55986}]},{"StartTime":165378.0,"Objects":[{"StartTime":165378.0,"EndTime":165378.0,"X":393.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":165504.0,"EndTime":165504.0,"X":406.0307,"Y":240.255035,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":165703.0,"Objects":[{"StartTime":165703.0,"EndTime":165703.0,"X":224.0,"Y":352.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":165829.0,"EndTime":165829.0,"X":127.282745,"Y":373.0001,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":166027.0,"Objects":[{"StartTime":166027.0,"EndTime":166027.0,"X":38.0,"Y":139.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":166315.0,"EndTime":166315.0,"X":94.76893,"Y":202.286728,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":166514.0,"Objects":[{"StartTime":166514.0,"EndTime":166514.0,"X":224.0,"Y":352.0}]},{"StartTime":166676.0,"Objects":[{"StartTime":166676.0,"EndTime":166676.0,"X":312.88028,"Y":211.88028}]},{"StartTime":166757.0,"Objects":[{"StartTime":166757.0,"EndTime":166757.0,"X":316.440155,"Y":215.44014}]},{"StartTime":166838.0,"Objects":[{"StartTime":166838.0,"EndTime":166838.0,"X":320.0,"Y":219.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":166964.0,"EndTime":166964.0,"X":335.705536,"Y":316.9286,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":167162.0,"Objects":[{"StartTime":167162.0,"EndTime":167162.0,"X":208.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":167369.0,"EndTime":167369.0,"X":314.63,"Y":110.89566,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":167487.0,"Objects":[{"StartTime":167487.0,"EndTime":167487.0,"X":383.440155,"Y":81.44014}]},{"StartTime":167811.0,"Objects":[{"StartTime":167811.0,"EndTime":167811.0,"X":387.0,"Y":85.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":167937.0,"EndTime":167937.0,"X":402.5302,"Y":182.887451,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":168135.0,"Objects":[{"StartTime":168135.0,"EndTime":168135.0,"X":247.0,"Y":0.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":168261.0,"EndTime":168261.0,"X":227.808548,"Y":96.07656,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":168460.0,"Objects":[{"StartTime":168460.0,"EndTime":168460.0,"X":51.0,"Y":253.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":168667.0,"EndTime":168667.0,"X":160.370071,"Y":209.627731,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":168784.0,"Objects":[{"StartTime":168784.0,"EndTime":168784.0,"X":184.0,"Y":282.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":168910.0,"EndTime":168910.0,"X":277.386383,"Y":312.846649,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":169108.0,"Objects":[{"StartTime":169108.0,"EndTime":169108.0,"X":402.0,"Y":182.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":169234.0,"EndTime":169234.0,"X":307.239227,"Y":209.268143,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":169432.0,"Objects":[{"StartTime":169432.0,"EndTime":169432.0,"X":414.0,"Y":367.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":169558.0,"EndTime":169558.0,"X":429.1886,"Y":278.648743,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":169757.0,"Objects":[{"StartTime":169757.0,"EndTime":169757.0,"X":223.0,"Y":116.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":169883.0,"EndTime":169883.0,"X":233.536072,"Y":214.5363,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":170081.0,"Objects":[{"StartTime":170081.0,"EndTime":170081.0,"X":488.0,"Y":67.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":170288.0,"EndTime":170288.0,"X":367.508942,"Y":32.84226,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":170405.0,"Objects":[{"StartTime":170405.0,"EndTime":170405.0,"X":319.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":170531.0,"EndTime":170531.0,"X":222.873062,"Y":115.492477,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":170730.0,"Objects":[{"StartTime":170730.0,"EndTime":170730.0,"X":393.0,"Y":237.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":170856.0,"EndTime":170856.0,"X":487.852,"Y":265.337646,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":171054.0,"Objects":[{"StartTime":171054.0,"EndTime":171054.0,"X":308.0,"Y":384.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":171180.0,"EndTime":171180.0,"X":293.36615,"Y":298.094452,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":171378.0,"Objects":[{"StartTime":171378.0,"EndTime":171378.0,"X":195.0,"Y":44.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":171585.0,"EndTime":171585.0,"X":177.52536,"Y":191.740128,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":171703.0,"Objects":[{"StartTime":171703.0,"EndTime":171703.0,"X":230.0,"Y":247.0}]},{"StartTime":171865.0,"Objects":[{"StartTime":171865.0,"EndTime":171865.0,"X":31.0,"Y":366.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":172153.0,"EndTime":172153.0,"X":98.42071,"Y":234.214081,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":172351.0,"Objects":[{"StartTime":172351.0,"EndTime":172351.0,"X":204.0,"Y":62.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":172558.0,"EndTime":172558.0,"X":71.31268,"Y":15.4140854,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":172676.0,"Objects":[{"StartTime":172676.0,"EndTime":172676.0,"X":14.0,"Y":62.0}]},{"StartTime":172838.0,"Objects":[{"StartTime":172838.0,"EndTime":172838.0,"X":161.0,"Y":175.0}]},{"StartTime":173000.0,"Objects":[{"StartTime":173000.0,"EndTime":173000.0,"X":35.0,"Y":149.0}]},{"StartTime":173162.0,"Objects":[{"StartTime":173162.0,"EndTime":173162.0,"X":204.0,"Y":62.0}]},{"StartTime":173324.0,"Objects":[{"StartTime":173324.0,"EndTime":173324.0,"X":262.0,"Y":266.0}]},{"StartTime":173487.0,"Objects":[{"StartTime":173487.0,"EndTime":173487.0,"X":124.0,"Y":91.0}]},{"StartTime":173649.0,"Objects":[{"StartTime":173649.0,"EndTime":173649.0,"X":123.0,"Y":310.0}]},{"StartTime":173811.0,"Objects":[{"StartTime":173811.0,"EndTime":173811.0,"X":265.0,"Y":107.0}]},{"StartTime":173973.0,"Objects":[{"StartTime":173973.0,"EndTime":173973.0,"X":319.0,"Y":381.0}]},{"StartTime":174135.0,"Objects":[{"StartTime":174135.0,"EndTime":174135.0,"X":161.0,"Y":175.0}]},{"StartTime":174297.0,"Objects":[{"StartTime":174297.0,"EndTime":174297.0,"X":363.0,"Y":258.0}]},{"StartTime":174460.0,"Objects":[{"StartTime":174460.0,"EndTime":174460.0,"X":155.0,"Y":384.0}]},{"StartTime":174622.0,"Objects":[{"StartTime":174622.0,"EndTime":174622.0,"X":364.0,"Y":59.0}]},{"StartTime":174784.0,"Objects":[{"StartTime":174784.0,"EndTime":174784.0,"X":410.0,"Y":341.0}]},{"StartTime":174946.0,"Objects":[{"StartTime":174946.0,"EndTime":174946.0,"X":205.0,"Y":35.0}]},{"StartTime":175108.0,"Objects":[{"StartTime":175108.0,"EndTime":175108.0,"X":123.0,"Y":310.0}]},{"StartTime":175270.0,"Objects":[{"StartTime":175270.0,"EndTime":175270.0,"X":411.0,"Y":183.0}]},{"StartTime":175432.0,"Objects":[{"StartTime":175432.0,"EndTime":175432.0,"X":80.0,"Y":49.0}]},{"StartTime":176081.0,"Objects":[{"StartTime":176081.0,"EndTime":176081.0,"X":67.0,"Y":42.0}]},{"StartTime":176243.0,"Objects":[{"StartTime":176243.0,"EndTime":176243.0,"X":231.0,"Y":249.0}]},{"StartTime":176405.0,"Objects":[{"StartTime":176405.0,"EndTime":176405.0,"X":433.0,"Y":101.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":176612.0,"EndTime":176612.0,"X":374.4514,"Y":152.1901,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":176730.0,"Objects":[{"StartTime":176730.0,"EndTime":176730.0,"X":427.0,"Y":207.0}]},{"StartTime":176892.0,"Objects":[{"StartTime":176892.0,"EndTime":176892.0,"X":281.0,"Y":95.0}]},{"StartTime":177054.0,"Objects":[{"StartTime":177054.0,"EndTime":177054.0,"X":471.0,"Y":27.0}]},{"StartTime":177216.0,"Objects":[{"StartTime":177216.0,"EndTime":177216.0,"X":265.88028,"Y":211.88028}]},{"StartTime":177297.0,"Objects":[{"StartTime":177297.0,"EndTime":177297.0,"X":269.440155,"Y":215.44014}]},{"StartTime":177378.0,"Objects":[{"StartTime":177378.0,"EndTime":177378.0,"X":273.0,"Y":219.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":177423.0,"EndTime":177423.0,"X":221.380753,"Y":195.4054,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":177541.0,"Objects":[{"StartTime":177541.0,"EndTime":177541.0,"X":377.0,"Y":361.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":177748.0,"EndTime":177748.0,"X":429.262421,"Y":340.613739,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":177865.0,"Objects":[{"StartTime":177865.0,"EndTime":177865.0,"X":465.0,"Y":274.0}]},{"StartTime":182892.0,"Objects":[{"StartTime":182892.0,"EndTime":182892.0,"X":100.0,"Y":334.0}]},{"StartTime":183054.0,"Objects":[{"StartTime":183054.0,"EndTime":183054.0,"X":72.0,"Y":268.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":183378.0,"EndTime":183378.0,"X":176.303543,"Y":275.243622,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":183504.0,"EndTime":183504.0,"X":206.569153,"Y":320.91394,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":183703.0,"Objects":[{"StartTime":183703.0,"EndTime":183703.0,"X":328.0,"Y":140.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":183991.0,"EndTime":183991.0,"X":219.648621,"Y":165.7105,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":184189.0,"Objects":[{"StartTime":184189.0,"EndTime":184189.0,"X":341.88028,"Y":296.88028}]},{"StartTime":184270.0,"Objects":[{"StartTime":184270.0,"EndTime":184270.0,"X":345.440155,"Y":300.440155}]},{"StartTime":184351.0,"Objects":[{"StartTime":184351.0,"EndTime":184351.0,"X":349.0,"Y":304.0}]},{"StartTime":184838.0,"Objects":[{"StartTime":184838.0,"EndTime":184838.0,"X":349.0,"Y":304.0}]},{"StartTime":185000.0,"Objects":[{"StartTime":185000.0,"EndTime":185000.0,"X":219.0,"Y":165.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":185126.0,"EndTime":185126.0,"X":230.723755,"Y":110.568245,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":185324.0,"Objects":[{"StartTime":185324.0,"EndTime":185324.0,"X":353.0,"Y":62.0}]},{"StartTime":185568.0,"Objects":[{"StartTime":185568.0,"EndTime":185568.0,"X":121.0,"Y":275.0}]},{"StartTime":185649.0,"Objects":[{"StartTime":185649.0,"EndTime":185649.0,"X":129.0,"Y":262.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":185775.0,"EndTime":185775.0,"X":200.858246,"Y":248.692917,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":185892.0,"Objects":[{"StartTime":185892.0,"EndTime":185892.0,"X":272.0,"Y":234.0}]},{"StartTime":185973.0,"Objects":[{"StartTime":185973.0,"EndTime":185973.0,"X":295.0,"Y":231.0}]},{"StartTime":186135.0,"Objects":[{"StartTime":186135.0,"EndTime":186135.0,"X":219.0,"Y":165.0}]},{"StartTime":186297.0,"Objects":[{"StartTime":186297.0,"EndTime":186297.0,"X":349.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":186423.0,"EndTime":186423.0,"X":334.49353,"Y":375.625732,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":186622.0,"Objects":[{"StartTime":186622.0,"EndTime":186622.0,"X":468.0,"Y":214.0}]},{"StartTime":186784.0,"Objects":[{"StartTime":186784.0,"EndTime":186784.0,"X":398.0,"Y":238.0}]},{"StartTime":186946.0,"Objects":[{"StartTime":186946.0,"EndTime":186946.0,"X":432.0,"Y":55.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":187072.0,"EndTime":187072.0,"X":446.1718,"Y":126.692726,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":187189.0,"Objects":[{"StartTime":187189.0,"EndTime":187189.0,"X":464.0,"Y":196.0}]},{"StartTime":187270.0,"Objects":[{"StartTime":187270.0,"EndTime":187270.0,"X":468.0,"Y":214.0}]},{"StartTime":187432.0,"Objects":[{"StartTime":187432.0,"EndTime":187432.0,"X":338.0,"Y":93.0}]},{"StartTime":187595.0,"Objects":[{"StartTime":187595.0,"EndTime":187595.0,"X":219.0,"Y":165.0}]},{"StartTime":187676.0,"Objects":[{"StartTime":187676.0,"EndTime":187676.0,"X":187.0,"Y":181.0}]},{"StartTime":187757.0,"Objects":[{"StartTime":187757.0,"EndTime":187757.0,"X":157.0,"Y":164.0}]},{"StartTime":187838.0,"Objects":[{"StartTime":187838.0,"EndTime":187838.0,"X":152.0,"Y":127.0}]},{"StartTime":187919.0,"Objects":[{"StartTime":187919.0,"EndTime":187919.0,"X":177.0,"Y":105.0}]},{"StartTime":188081.0,"Objects":[{"StartTime":188081.0,"EndTime":188081.0,"X":309.88028,"Y":225.88028}]},{"StartTime":188162.0,"Objects":[{"StartTime":188162.0,"EndTime":188162.0,"X":313.440155,"Y":229.44014}]},{"StartTime":188243.0,"Objects":[{"StartTime":188243.0,"EndTime":188243.0,"X":317.0,"Y":233.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":188450.0,"EndTime":188450.0,"X":296.006622,"Y":340.591,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":188568.0,"Objects":[{"StartTime":188568.0,"EndTime":188568.0,"X":353.0,"Y":384.0}]},{"StartTime":188730.0,"Objects":[{"StartTime":188730.0,"EndTime":188730.0,"X":216.0,"Y":275.0}]},{"StartTime":188892.0,"Objects":[{"StartTime":188892.0,"EndTime":188892.0,"X":385.0,"Y":171.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":189018.0,"EndTime":189018.0,"X":398.602173,"Y":242.736786,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":189216.0,"Objects":[{"StartTime":189216.0,"EndTime":189216.0,"X":194.0,"Y":34.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":189342.0,"EndTime":189342.0,"X":176.858063,"Y":104.771019,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":189460.0,"Objects":[{"StartTime":189460.0,"EndTime":189460.0,"X":118.44014,"Y":152.44014}]},{"StartTime":189541.0,"Objects":[{"StartTime":189541.0,"EndTime":189541.0,"X":122.0,"Y":156.0}]},{"StartTime":189703.0,"Objects":[{"StartTime":189703.0,"EndTime":189703.0,"X":293.88028,"Y":37.8802834}]},{"StartTime":189784.0,"Objects":[{"StartTime":189784.0,"EndTime":189784.0,"X":297.440155,"Y":41.44014}]},{"StartTime":189865.0,"Objects":[{"StartTime":189865.0,"EndTime":189865.0,"X":301.0,"Y":45.0}]},{"StartTime":190027.0,"Objects":[{"StartTime":190027.0,"EndTime":190027.0,"X":385.0,"Y":171.0}]},{"StartTime":190189.0,"Objects":[{"StartTime":190189.0,"EndTime":190189.0,"X":186.88028,"Y":26.8802814}]},{"StartTime":190270.0,"Objects":[{"StartTime":190270.0,"EndTime":190270.0,"X":190.44014,"Y":30.4401417}]},{"StartTime":190351.0,"Objects":[{"StartTime":190351.0,"EndTime":190351.0,"X":194.0,"Y":34.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":190396.0,"EndTime":190396.0,"X":199.64093,"Y":70.10196,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":190514.0,"Objects":[{"StartTime":190514.0,"EndTime":190514.0,"X":34.8802834,"Y":240.88028}]},{"StartTime":190595.0,"Objects":[{"StartTime":190595.0,"EndTime":190595.0,"X":38.44014,"Y":244.44014}]},{"StartTime":190676.0,"Objects":[{"StartTime":190676.0,"EndTime":190676.0,"X":42.0,"Y":248.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":190721.0,"EndTime":190721.0,"X":47.64093,"Y":211.898041,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":190838.0,"Objects":[{"StartTime":190838.0,"EndTime":190838.0,"X":16.0,"Y":145.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":191162.0,"EndTime":191162.0,"X":119.469978,"Y":147.29715,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":191288.0,"EndTime":191288.0,"X":148.708221,"Y":193.475845,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":191487.0,"Objects":[{"StartTime":191487.0,"EndTime":191487.0,"X":296.0,"Y":345.0}]},{"StartTime":191568.0,"Objects":[{"StartTime":191568.0,"EndTime":191568.0,"X":261.0,"Y":370.0}]},{"StartTime":191649.0,"Objects":[{"StartTime":191649.0,"EndTime":191649.0,"X":221.0,"Y":354.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":191694.0,"EndTime":191694.0,"X":213.887466,"Y":298.299835,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":191811.0,"Objects":[{"StartTime":191811.0,"EndTime":191811.0,"X":378.0,"Y":138.0}]},{"StartTime":191892.0,"Objects":[{"StartTime":191892.0,"EndTime":191892.0,"X":343.0,"Y":113.0}]},{"StartTime":191973.0,"Objects":[{"StartTime":191973.0,"EndTime":191973.0,"X":303.0,"Y":129.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":192018.0,"EndTime":192018.0,"X":295.887482,"Y":184.700165,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":192135.0,"Objects":[{"StartTime":192135.0,"EndTime":192135.0,"X":352.0,"Y":270.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":192423.0,"EndTime":192423.0,"X":169.461578,"Y":325.467743,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":192622.0,"Objects":[{"StartTime":192622.0,"EndTime":192622.0,"X":295.0,"Y":184.0}]},{"StartTime":192784.0,"Objects":[{"StartTime":192784.0,"EndTime":192784.0,"X":129.0,"Y":67.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":192910.0,"EndTime":192910.0,"X":214.250961,"Y":97.42061,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":193108.0,"Objects":[{"StartTime":193108.0,"EndTime":193108.0,"X":87.0,"Y":223.0}]},{"StartTime":193270.0,"Objects":[{"StartTime":193270.0,"EndTime":193270.0,"X":223.88028,"Y":72.88028}]},{"StartTime":193351.0,"Objects":[{"StartTime":193351.0,"EndTime":193351.0,"X":227.44014,"Y":76.44014}]},{"StartTime":193432.0,"Objects":[{"StartTime":193432.0,"EndTime":193432.0,"X":231.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":193639.0,"EndTime":193639.0,"X":355.849518,"Y":39.56346,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":193757.0,"Objects":[{"StartTime":193757.0,"EndTime":193757.0,"X":414.0,"Y":88.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":193883.0,"EndTime":193883.0,"X":393.473846,"Y":185.032715,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":194081.0,"Objects":[{"StartTime":194081.0,"EndTime":194081.0,"X":273.0,"Y":328.0}]},{"StartTime":194243.0,"Objects":[{"StartTime":194243.0,"EndTime":194243.0,"X":486.0,"Y":204.0}]},{"StartTime":194405.0,"Objects":[{"StartTime":194405.0,"EndTime":194405.0,"X":307.0,"Y":111.0}]},{"StartTime":194568.0,"Objects":[{"StartTime":194568.0,"EndTime":194568.0,"X":431.0,"Y":324.0}]},{"StartTime":194649.0,"Objects":[{"StartTime":194649.0,"EndTime":194649.0,"X":434.0,"Y":307.0}]},{"StartTime":194730.0,"Objects":[{"StartTime":194730.0,"EndTime":194730.0,"X":437.0,"Y":293.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":195018.0,"EndTime":195018.0,"X":272.098663,"Y":327.366364,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":195216.0,"Objects":[{"StartTime":195216.0,"EndTime":195216.0,"X":119.0,"Y":181.0}]},{"StartTime":195378.0,"Objects":[{"StartTime":195378.0,"EndTime":195378.0,"X":30.0,"Y":306.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":195504.0,"EndTime":195504.0,"X":126.266693,"Y":283.16925,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":195703.0,"Objects":[{"StartTime":195703.0,"EndTime":195703.0,"X":231.0,"Y":143.0}]},{"StartTime":195865.0,"Objects":[{"StartTime":195865.0,"EndTime":195865.0,"X":94.88028,"Y":25.8802814}]},{"StartTime":195946.0,"Objects":[{"StartTime":195946.0,"EndTime":195946.0,"X":98.44014,"Y":29.4401417}]},{"StartTime":196027.0,"Objects":[{"StartTime":196027.0,"EndTime":196027.0,"X":102.0,"Y":33.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":196153.0,"EndTime":196153.0,"X":197.922256,"Y":56.95418,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":196351.0,"Objects":[{"StartTime":196351.0,"EndTime":196351.0,"X":89.0,"Y":175.0}]},{"StartTime":196514.0,"Objects":[{"StartTime":196514.0,"EndTime":196514.0,"X":260.0,"Y":250.0}]},{"StartTime":196676.0,"Objects":[{"StartTime":196676.0,"EndTime":196676.0,"X":97.0,"Y":370.0}]},{"StartTime":196838.0,"Objects":[{"StartTime":196838.0,"EndTime":196838.0,"X":239.88028,"Y":155.88028}]},{"StartTime":196919.0,"Objects":[{"StartTime":196919.0,"EndTime":196919.0,"X":243.44014,"Y":159.44014}]},{"StartTime":197000.0,"Objects":[{"StartTime":197000.0,"EndTime":197000.0,"X":247.0,"Y":163.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":197045.0,"EndTime":197045.0,"X":295.152618,"Y":174.852951,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":197162.0,"Objects":[{"StartTime":197162.0,"EndTime":197162.0,"X":473.0,"Y":339.0}]},{"StartTime":197243.0,"Objects":[{"StartTime":197243.0,"EndTime":197243.0,"X":441.0,"Y":363.0}]},{"StartTime":197324.0,"Objects":[{"StartTime":197324.0,"EndTime":197324.0,"X":403.0,"Y":349.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":197531.0,"EndTime":197531.0,"X":422.4077,"Y":201.501343,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":197649.0,"Objects":[{"StartTime":197649.0,"EndTime":197649.0,"X":472.0,"Y":140.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":197775.0,"EndTime":197775.0,"X":497.565216,"Y":225.263321,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":197973.0,"Objects":[{"StartTime":197973.0,"EndTime":197973.0,"X":344.0,"Y":101.0}]},{"StartTime":198135.0,"Objects":[{"StartTime":198135.0,"EndTime":198135.0,"X":470.0,"Y":38.0}]},{"StartTime":198297.0,"Objects":[{"StartTime":198297.0,"EndTime":198297.0,"X":347.0,"Y":231.0}]},{"StartTime":198460.0,"Objects":[{"StartTime":198460.0,"EndTime":198460.0,"X":200.88028,"Y":1.88028193}]},{"StartTime":198541.0,"Objects":[{"StartTime":198541.0,"EndTime":198541.0,"X":204.44014,"Y":5.44014072}]},{"StartTime":198622.0,"Objects":[{"StartTime":198622.0,"EndTime":198622.0,"X":208.0,"Y":9.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":198748.0,"EndTime":198748.0,"X":301.888275,"Y":39.795578,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":198946.0,"Objects":[{"StartTime":198946.0,"EndTime":198946.0,"X":189.0,"Y":186.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":199153.0,"EndTime":199153.0,"X":49.7648544,"Y":228.957153,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":199270.0,"Objects":[{"StartTime":199270.0,"EndTime":199270.0,"X":-3.559859,"Y":171.44014}]},{"StartTime":199432.0,"Objects":[{"StartTime":199432.0,"EndTime":199432.0,"X":192.0,"Y":352.0}]},{"StartTime":199595.0,"Objects":[{"StartTime":199595.0,"EndTime":199595.0,"X":0.0,"Y":175.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":199721.0,"EndTime":199721.0,"X":22.2606564,"Y":78.42636,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":199919.0,"Objects":[{"StartTime":199919.0,"EndTime":199919.0,"X":244.0,"Y":262.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":200126.0,"EndTime":200126.0,"X":227.027512,"Y":114.432533,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":200243.0,"Objects":[{"StartTime":200243.0,"EndTime":200243.0,"X":174.0,"Y":61.0}]},{"StartTime":200405.0,"Objects":[{"StartTime":200405.0,"EndTime":200405.0,"X":49.0,"Y":228.0}]},{"StartTime":200568.0,"Objects":[{"StartTime":200568.0,"EndTime":200568.0,"X":259.0,"Y":127.0}]},{"StartTime":200730.0,"Objects":[{"StartTime":200730.0,"EndTime":200730.0,"X":78.88028,"Y":-5.119718}]},{"StartTime":200811.0,"Objects":[{"StartTime":200811.0,"EndTime":200811.0,"X":82.44014,"Y":-1.559859}]},{"StartTime":200892.0,"Objects":[{"StartTime":200892.0,"EndTime":200892.0,"X":86.0,"Y":2.0}]},{"StartTime":201054.0,"Objects":[{"StartTime":201054.0,"EndTime":201054.0,"X":244.0,"Y":262.0}]},{"StartTime":201216.0,"Objects":[{"StartTime":201216.0,"EndTime":201216.0,"X":23.0,"Y":124.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201342.0,"EndTime":201342.0,"X":118.862045,"Y":99.96171,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201541.0,"Objects":[{"StartTime":201541.0,"EndTime":201541.0,"X":293.0,"Y":0.0}]},{"StartTime":201703.0,"Objects":[{"StartTime":201703.0,"EndTime":201703.0,"X":447.0,"Y":147.0}]},{"StartTime":201784.0,"Objects":[{"StartTime":201784.0,"EndTime":201784.0,"X":428.0,"Y":166.0}]},{"StartTime":201865.0,"Objects":[{"StartTime":201865.0,"EndTime":201865.0,"X":397.0,"Y":165.0}]},{"StartTime":202027.0,"Objects":[{"StartTime":202027.0,"EndTime":202027.0,"X":483.0,"Y":51.0}]},{"StartTime":202189.0,"Objects":[{"StartTime":202189.0,"EndTime":202189.0,"X":380.0,"Y":149.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":202396.0,"EndTime":202396.0,"X":257.8795,"Y":176.7536,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":202514.0,"Objects":[{"StartTime":202514.0,"EndTime":202514.0,"X":186.0,"Y":215.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":202802.0,"EndTime":202802.0,"X":368.3096,"Y":273.459625,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":203000.0,"Objects":[{"StartTime":203000.0,"EndTime":203000.0,"X":208.0,"Y":96.0}]},{"StartTime":203162.0,"Objects":[{"StartTime":203162.0,"EndTime":203162.0,"X":261.0,"Y":329.0}]},{"StartTime":203324.0,"Objects":[{"StartTime":203324.0,"EndTime":203324.0,"X":380.0,"Y":149.0}]},{"StartTime":203487.0,"Objects":[{"StartTime":203487.0,"EndTime":203487.0,"X":186.0,"Y":215.0}]},{"StartTime":203649.0,"Objects":[{"StartTime":203649.0,"EndTime":203649.0,"X":378.0,"Y":364.0}]},{"StartTime":203811.0,"Objects":[{"StartTime":203811.0,"EndTime":203811.0,"X":279.0,"Y":239.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":204099.0,"EndTime":204099.0,"X":98.91592,"Y":298.552063,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":204297.0,"Objects":[{"StartTime":204297.0,"EndTime":204297.0,"X":281.0,"Y":153.0}]},{"StartTime":204460.0,"Objects":[{"StartTime":204460.0,"EndTime":204460.0,"X":110.0,"Y":46.0}]},{"StartTime":204622.0,"Objects":[{"StartTime":204622.0,"EndTime":204622.0,"X":186.0,"Y":215.0}]},{"StartTime":204784.0,"Objects":[{"StartTime":204784.0,"EndTime":204784.0,"X":315.0,"Y":72.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":204991.0,"EndTime":204991.0,"X":435.8663,"Y":118.276,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":205108.0,"Objects":[{"StartTime":205108.0,"EndTime":205108.0,"X":512.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":205396.0,"EndTime":205396.0,"X":380.2276,"Y":228.202179,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":205595.0,"Objects":[{"StartTime":205595.0,"EndTime":205595.0,"X":512.0,"Y":96.0}]},{"StartTime":205757.0,"Objects":[{"StartTime":205757.0,"EndTime":205757.0,"X":302.0,"Y":224.0}]},{"StartTime":205919.0,"Objects":[{"StartTime":205919.0,"EndTime":205919.0,"X":496.0,"Y":334.0}]},{"StartTime":206081.0,"Objects":[{"StartTime":206081.0,"EndTime":206081.0,"X":363.0,"Y":152.0}]},{"StartTime":206243.0,"Objects":[{"StartTime":206243.0,"EndTime":206243.0,"X":226.0,"Y":365.0}]},{"StartTime":206405.0,"Objects":[{"StartTime":206405.0,"EndTime":206405.0,"X":377.0,"Y":226.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":206693.0,"EndTime":206693.0,"X":208.3632,"Y":146.109634,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":206892.0,"Objects":[{"StartTime":206892.0,"EndTime":206892.0,"X":317.0,"Y":310.0}]},{"StartTime":207054.0,"Objects":[{"StartTime":207054.0,"EndTime":207054.0,"X":282.0,"Y":65.0}]},{"StartTime":207216.0,"Objects":[{"StartTime":207216.0,"EndTime":207216.0,"X":108.0,"Y":232.0}]},{"StartTime":207378.0,"Objects":[{"StartTime":207378.0,"EndTime":207378.0,"X":0.0,"Y":163.0}]},{"StartTime":207460.0,"Objects":[{"StartTime":207460.0,"EndTime":207460.0,"X":21.0,"Y":119.0}]},{"StartTime":207541.0,"Objects":[{"StartTime":207541.0,"EndTime":207541.0,"X":65.0,"Y":101.0}]},{"StartTime":207622.0,"Objects":[{"StartTime":207622.0,"EndTime":207622.0,"X":116.0,"Y":115.0}]},{"StartTime":207703.0,"Objects":[{"StartTime":207703.0,"EndTime":207703.0,"X":131.0,"Y":132.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":207829.0,"EndTime":207829.0,"X":113.029633,"Y":202.713181,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":208027.0,"Objects":[{"StartTime":208027.0,"EndTime":208027.0,"X":209.0,"Y":284.0}]},{"StartTime":208189.0,"Objects":[{"StartTime":208189.0,"EndTime":208189.0,"X":87.0,"Y":342.0}]},{"StartTime":208351.0,"Objects":[{"StartTime":208351.0,"EndTime":208351.0,"X":228.0,"Y":206.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":208477.0,"EndTime":208477.0,"X":295.507141,"Y":233.443161,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":208676.0,"Objects":[{"StartTime":208676.0,"EndTime":208676.0,"X":317.0,"Y":310.0}]},{"StartTime":208838.0,"Objects":[{"StartTime":208838.0,"EndTime":208838.0,"X":468.0,"Y":196.0}]},{"StartTime":208919.0,"Objects":[{"StartTime":208919.0,"EndTime":208919.0,"X":457.0,"Y":192.0}]},{"StartTime":209000.0,"Objects":[{"StartTime":209000.0,"EndTime":209000.0,"X":444.0,"Y":186.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":209126.0,"EndTime":209126.0,"X":451.721558,"Y":258.535278,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":209324.0,"Objects":[{"StartTime":209324.0,"EndTime":209324.0,"X":302.0,"Y":84.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":209450.0,"EndTime":209450.0,"X":286.32312,"Y":155.0472,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":209649.0,"Objects":[{"StartTime":209649.0,"EndTime":209649.0,"X":445.0,"Y":43.0}]},{"StartTime":209892.0,"Objects":[{"StartTime":209892.0,"EndTime":209892.0,"X":228.0,"Y":206.0}]},{"StartTime":210135.0,"Objects":[{"StartTime":210135.0,"EndTime":210135.0,"X":447.0,"Y":384.0}]},{"StartTime":210297.0,"Objects":[{"StartTime":210297.0,"EndTime":210297.0,"X":313.0,"Y":229.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":210504.0,"EndTime":210504.0,"X":172.0766,"Y":265.49353,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":210622.0,"Objects":[{"StartTime":210622.0,"EndTime":210622.0,"X":138.0,"Y":334.0}]},{"StartTime":210784.0,"Objects":[{"StartTime":210784.0,"EndTime":210784.0,"X":6.0,"Y":131.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":211072.0,"EndTime":211072.0,"X":112.5638,"Y":170.578735,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":211189.0,"Objects":[{"StartTime":211189.0,"EndTime":211189.0,"X":52.0,"Y":220.0}]},{"StartTime":211270.0,"Objects":[{"StartTime":211270.0,"EndTime":211270.0,"X":47.0,"Y":207.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":211477.0,"EndTime":211477.0,"X":171.609314,"Y":265.431152,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":211595.0,"Objects":[{"StartTime":211595.0,"EndTime":211595.0,"X":226.0,"Y":322.0}]},{"StartTime":211757.0,"Objects":[{"StartTime":211757.0,"EndTime":211757.0,"X":381.0,"Y":194.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":212045.0,"EndTime":212045.0,"X":275.428528,"Y":97.02175,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":212162.0,"Objects":[{"StartTime":212162.0,"EndTime":212162.0,"X":233.44014,"Y":157.44014}]},{"StartTime":212243.0,"Objects":[{"StartTime":212243.0,"EndTime":212243.0,"X":237.0,"Y":161.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":212288.0,"EndTime":212288.0,"X":284.574951,"Y":174.99263,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":212405.0,"Objects":[{"StartTime":212405.0,"EndTime":212405.0,"X":422.0,"Y":292.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":212450.0,"EndTime":212450.0,"X":374.446442,"Y":306.065125,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":212568.0,"Objects":[{"StartTime":212568.0,"EndTime":212568.0,"X":451.0,"Y":128.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":212613.0,"EndTime":212613.0,"X":458.834747,"Y":176.967178,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":212730.0,"Objects":[{"StartTime":212730.0,"EndTime":212730.0,"X":263.0,"Y":324.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":212775.0,"EndTime":212775.0,"X":272.138855,"Y":275.259369,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":212892.0,"Objects":[{"StartTime":212892.0,"EndTime":212892.0,"X":455.0,"Y":384.0}]},{"StartTime":213054.0,"Objects":[{"StartTime":213054.0,"EndTime":213054.0,"X":422.0,"Y":292.0}]},{"StartTime":213216.0,"Objects":[{"StartTime":213216.0,"EndTime":213216.0,"X":321.0,"Y":384.0}]},{"StartTime":213378.0,"Objects":[{"StartTime":213378.0,"EndTime":213378.0,"X":378.0,"Y":188.0}]},{"StartTime":213541.0,"Objects":[{"StartTime":213541.0,"EndTime":213541.0,"X":194.0,"Y":379.0}]},{"StartTime":213703.0,"Objects":[{"StartTime":213703.0,"EndTime":213703.0,"X":130.0,"Y":108.0}]},{"StartTime":213865.0,"Objects":[{"StartTime":213865.0,"EndTime":213865.0,"X":341.0,"Y":272.0}]},{"StartTime":214027.0,"Objects":[{"StartTime":214027.0,"EndTime":214027.0,"X":63.0,"Y":384.0}]},{"StartTime":214189.0,"Objects":[{"StartTime":214189.0,"EndTime":214189.0,"X":243.0,"Y":34.0}]},{"StartTime":214351.0,"Objects":[{"StartTime":214351.0,"EndTime":214351.0,"X":321.0,"Y":384.0}]},{"StartTime":215162.0,"Objects":[{"StartTime":215162.0,"EndTime":215162.0,"X":15.0,"Y":145.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":215369.0,"EndTime":215369.0,"X":94.9599457,"Y":184.9499,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":215487.0,"Objects":[{"StartTime":215487.0,"EndTime":215487.0,"X":172.0,"Y":178.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":215775.0,"EndTime":215775.0,"X":352.33728,"Y":238.509,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":215973.0,"Objects":[{"StartTime":215973.0,"EndTime":215973.0,"X":233.0,"Y":321.0}]},{"StartTime":216135.0,"Objects":[{"StartTime":216135.0,"EndTime":216135.0,"X":406.0,"Y":180.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":216261.0,"EndTime":216261.0,"X":429.166473,"Y":265.959167,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":216460.0,"Objects":[{"StartTime":216460.0,"EndTime":216460.0,"X":261.0,"Y":148.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":216667.0,"EndTime":216667.0,"X":384.301575,"Y":70.8233643,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":216784.0,"Objects":[{"StartTime":216784.0,"EndTime":216784.0,"X":461.0,"Y":67.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":217072.0,"EndTime":217072.0,"X":285.659058,"Y":-0.6762123,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":217270.0,"Objects":[{"StartTime":217270.0,"EndTime":217270.0,"X":406.0,"Y":180.0}]},{"StartTime":217432.0,"Objects":[{"StartTime":217432.0,"EndTime":217432.0,"X":277.0,"Y":7.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":217558.0,"EndTime":217558.0,"X":180.3599,"Y":29.3015652,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":217757.0,"Objects":[{"StartTime":217757.0,"EndTime":217757.0,"X":23.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":217964.0,"EndTime":217964.0,"X":148.637451,"Y":187.654114,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":218081.0,"Objects":[{"StartTime":218081.0,"EndTime":218081.0,"X":231.0,"Y":234.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":218369.0,"EndTime":218369.0,"X":104.144432,"Y":308.3843,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":218568.0,"Objects":[{"StartTime":218568.0,"EndTime":218568.0,"X":278.0,"Y":165.0}]},{"StartTime":218730.0,"Objects":[{"StartTime":218730.0,"EndTime":218730.0,"X":348.0,"Y":279.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":218856.0,"EndTime":218856.0,"X":436.208221,"Y":306.333,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":219054.0,"Objects":[{"StartTime":219054.0,"EndTime":219054.0,"X":299.0,"Y":68.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":219180.0,"EndTime":219180.0,"X":277.9312,"Y":164.916351,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":219378.0,"Objects":[{"StartTime":219378.0,"EndTime":219378.0,"X":376.0,"Y":7.0}]},{"StartTime":219541.0,"Objects":[{"StartTime":219541.0,"EndTime":219541.0,"X":204.0,"Y":94.0}]},{"StartTime":219703.0,"Objects":[{"StartTime":219703.0,"EndTime":219703.0,"X":361.0,"Y":202.0}]},{"StartTime":219865.0,"Objects":[{"StartTime":219865.0,"EndTime":219865.0,"X":185.0,"Y":326.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":219991.0,"EndTime":219991.0,"X":205.094421,"Y":228.876953,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":220189.0,"Objects":[{"StartTime":220189.0,"EndTime":220189.0,"X":389.0,"Y":313.0}]},{"StartTime":220271.0,"Objects":[{"StartTime":220271.0,"EndTime":220271.0,"X":388.0,"Y":356.0}]},{"StartTime":220352.0,"Objects":[{"StartTime":220352.0,"EndTime":220352.0,"X":354.0,"Y":384.0}]},{"StartTime":220433.0,"Objects":[{"StartTime":220433.0,"EndTime":220433.0,"X":313.0,"Y":384.0}]},{"StartTime":220514.0,"Objects":[{"StartTime":220514.0,"EndTime":220514.0,"X":285.0,"Y":353.0}]},{"StartTime":220676.0,"Objects":[{"StartTime":220676.0,"EndTime":220676.0,"X":205.0,"Y":228.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":220964.0,"EndTime":220964.0,"X":45.86247,"Y":183.3497,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":221162.0,"Objects":[{"StartTime":221162.0,"EndTime":221162.0,"X":202.0,"Y":91.0}]},{"StartTime":221324.0,"Objects":[{"StartTime":221324.0,"EndTime":221324.0,"X":44.0,"Y":0.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":221450.0,"EndTime":221450.0,"X":131.2635,"Y":30.0638027,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":221649.0,"Objects":[{"StartTime":221649.0,"EndTime":221649.0,"X":45.0,"Y":183.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":221775.0,"EndTime":221775.0,"X":25.2771187,"Y":280.18396,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":221892.0,"Objects":[{"StartTime":221892.0,"EndTime":221892.0,"X":74.44014,"Y":329.440155}]},{"StartTime":221973.0,"Objects":[{"StartTime":221973.0,"EndTime":221973.0,"X":78.0,"Y":333.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":222180.0,"EndTime":222180.0,"X":218.276413,"Y":372.293671,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":222297.0,"Objects":[{"StartTime":222297.0,"EndTime":222297.0,"X":285.0,"Y":341.0}]},{"StartTime":222460.0,"Objects":[{"StartTime":222460.0,"EndTime":222460.0,"X":184.0,"Y":211.0}]},{"StartTime":222622.0,"Objects":[{"StartTime":222622.0,"EndTime":222622.0,"X":306.440155,"Y":160.44014}]},{"StartTime":222784.0,"Objects":[{"StartTime":222784.0,"EndTime":222784.0,"X":163.0,"Y":325.0}]},{"StartTime":222946.0,"Objects":[{"StartTime":222946.0,"EndTime":222946.0,"X":310.0,"Y":164.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":223072.0,"EndTime":223072.0,"X":322.5219,"Y":65.61365,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":223189.0,"Objects":[{"StartTime":223189.0,"EndTime":223189.0,"X":264.440155,"Y":9.440141}]},{"StartTime":223270.0,"Objects":[{"StartTime":223270.0,"EndTime":223270.0,"X":268.0,"Y":13.0}]},{"StartTime":223432.0,"Objects":[{"StartTime":223432.0,"EndTime":223432.0,"X":406.0,"Y":106.0}]},{"StartTime":223595.0,"Objects":[{"StartTime":223595.0,"EndTime":223595.0,"X":184.0,"Y":211.0}]},{"StartTime":223757.0,"Objects":[{"StartTime":223757.0,"EndTime":223757.0,"X":292.0,"Y":88.0}]},{"StartTime":223919.0,"Objects":[{"StartTime":223919.0,"EndTime":223919.0,"X":361.0,"Y":307.0}]},{"StartTime":224081.0,"Objects":[{"StartTime":224081.0,"EndTime":224081.0,"X":206.88028,"Y":111.88028}]},{"StartTime":224162.0,"Objects":[{"StartTime":224162.0,"EndTime":224162.0,"X":210.44014,"Y":115.44014}]},{"StartTime":224243.0,"Objects":[{"StartTime":224243.0,"EndTime":224243.0,"X":214.0,"Y":119.0}]},{"StartTime":224405.0,"Objects":[{"StartTime":224405.0,"EndTime":224405.0,"X":125.44014,"Y":286.440155}]},{"StartTime":224730.0,"Objects":[{"StartTime":224730.0,"EndTime":224730.0,"X":129.0,"Y":290.0}]},{"StartTime":224892.0,"Objects":[{"StartTime":224892.0,"EndTime":224892.0,"X":86.0,"Y":152.0}]},{"StartTime":225054.0,"Objects":[{"StartTime":225054.0,"EndTime":225054.0,"X":155.0,"Y":177.0}]},{"StartTime":225216.0,"Objects":[{"StartTime":225216.0,"EndTime":225216.0,"X":31.0,"Y":270.0}]},{"StartTime":225378.0,"Objects":[{"StartTime":225378.0,"EndTime":225378.0,"X":186.0,"Y":353.0}]},{"StartTime":225541.0,"Objects":[{"StartTime":225541.0,"EndTime":225541.0,"X":112.0,"Y":377.0}]},{"StartTime":225865.0,"Objects":[{"StartTime":225865.0,"EndTime":225865.0,"X":410.0,"Y":158.0}]},{"StartTime":226027.0,"Objects":[{"StartTime":226027.0,"EndTime":226027.0,"X":350.0,"Y":203.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":226153.0,"EndTime":226153.0,"X":358.2194,"Y":258.07,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":226351.0,"Objects":[{"StartTime":226351.0,"EndTime":226351.0,"X":193.0,"Y":83.0}]},{"StartTime":226514.0,"Objects":[{"StartTime":226514.0,"EndTime":226514.0,"X":246.0,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":226640.0,"EndTime":226640.0,"X":235.239471,"Y":190.630341,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":226838.0,"Objects":[{"StartTime":226838.0,"EndTime":226838.0,"X":394.0,"Y":25.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":226964.0,"EndTime":226964.0,"X":340.226776,"Y":39.44654,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":227162.0,"Objects":[{"StartTime":227162.0,"EndTime":227162.0,"X":269.0,"Y":59.0}]},{"StartTime":227324.0,"Objects":[{"StartTime":227324.0,"EndTime":227324.0,"X":109.0,"Y":153.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":227450.0,"EndTime":227450.0,"X":162.537659,"Y":168.296478,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":227649.0,"Objects":[{"StartTime":227649.0,"EndTime":227649.0,"X":235.0,"Y":190.0}]},{"StartTime":227811.0,"Objects":[{"StartTime":227811.0,"EndTime":227811.0,"X":107.0,"Y":348.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":227973.0,"EndTime":227973.0,"X":98.98543,"Y":292.899841,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":228099.0,"EndTime":228099.0,"X":107.0,"Y":348.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":228297.0,"Objects":[{"StartTime":228297.0,"EndTime":228297.0,"X":176.0,"Y":384.0}]},{"StartTime":228460.0,"Objects":[{"StartTime":228460.0,"EndTime":228460.0,"X":321.0,"Y":307.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":228586.0,"EndTime":228586.0,"X":330.4079,"Y":252.12056,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":228784.0,"Objects":[{"StartTime":228784.0,"EndTime":228784.0,"X":271.0,"Y":203.0}]},{"StartTime":228946.0,"Objects":[{"StartTime":228946.0,"EndTime":228946.0,"X":437.0,"Y":70.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":229072.0,"EndTime":229072.0,"X":444.753723,"Y":125.137482,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":229270.0,"Objects":[{"StartTime":229270.0,"EndTime":229270.0,"X":394.0,"Y":174.0}]},{"StartTime":229432.0,"Objects":[{"StartTime":229432.0,"EndTime":229432.0,"X":291.0,"Y":42.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":229558.0,"EndTime":229558.0,"X":238.1773,"Y":24.3924389,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":229757.0,"Objects":[{"StartTime":229757.0,"EndTime":229757.0,"X":288.0,"Y":119.0}]},{"StartTime":229919.0,"Objects":[{"StartTime":229919.0,"EndTime":229919.0,"X":150.0,"Y":209.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":230081.0,"EndTime":230081.0,"X":204.107239,"Y":195.859665,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":230207.0,"EndTime":230207.0,"X":150.0,"Y":209.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":230405.0,"Objects":[{"StartTime":230405.0,"EndTime":230405.0,"X":88.0,"Y":167.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":230531.0,"EndTime":230531.0,"X":98.91975,"Y":112.401268,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":230730.0,"Objects":[{"StartTime":230730.0,"EndTime":230730.0,"X":274.0,"Y":285.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":230838.0,"EndTime":230838.0,"X":279.0154,"Y":321.7796,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":230910.0,"EndTime":230910.0,"X":274.0,"Y":285.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":231054.0,"Objects":[{"StartTime":231054.0,"EndTime":231054.0,"X":314.0,"Y":217.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":231342.0,"EndTime":231342.0,"X":406.0158,"Y":249.631241,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":231541.0,"Objects":[{"StartTime":231541.0,"EndTime":231541.0,"X":512.0,"Y":130.0}]},{"StartTime":231703.0,"Objects":[{"StartTime":231703.0,"EndTime":231703.0,"X":452.0,"Y":88.0}]},{"StartTime":231865.0,"Objects":[{"StartTime":231865.0,"EndTime":231865.0,"X":406.0,"Y":143.0}]},{"StartTime":232027.0,"Objects":[{"StartTime":232027.0,"EndTime":232027.0,"X":280.0,"Y":54.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":232153.0,"EndTime":232153.0,"X":289.960327,"Y":-0.7818756,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":232351.0,"Objects":[{"StartTime":232351.0,"EndTime":232351.0,"X":348.0,"Y":89.0}]},{"StartTime":232514.0,"Objects":[{"StartTime":232514.0,"EndTime":232514.0,"X":186.0,"Y":175.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":232640.0,"EndTime":232640.0,"X":179.09375,"Y":119.749969,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":232838.0,"Objects":[{"StartTime":232838.0,"EndTime":232838.0,"X":120.0,"Y":222.0}]},{"StartTime":233000.0,"Objects":[{"StartTime":233000.0,"EndTime":233000.0,"X":224.0,"Y":364.0}]},{"StartTime":233162.0,"Objects":[{"StartTime":233162.0,"EndTime":233162.0,"X":157.0,"Y":342.0}]},{"StartTime":233324.0,"Objects":[{"StartTime":233324.0,"EndTime":233324.0,"X":293.0,"Y":248.0}]},{"StartTime":233487.0,"Objects":[{"StartTime":233487.0,"EndTime":233487.0,"X":384.0,"Y":221.0}]}]} \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/2593923.osu b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/2593923.osu new file mode 100644 index 000000000000..49ac05b253f8 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/2593923.osu @@ -0,0 +1,1213 @@ +osu file format v14 + +[General] +AudioLeadIn: 0 +PreviewTime: 175189 +Countdown: 0 +SampleSet: Soft +StackLeniency: 0.9 +Mode: 0 +LetterboxInBreaks: 0 +WidescreenStoryboard: 1 + +[Difficulty] +HPDrainRate:6 +CircleSize:4.2 +OverallDifficulty:8.7 +ApproachRate:9.4 +SliderMultiplier:1.74 +SliderTickRate:1 + +[Events] +//Background and Video events +//Break Periods +2,178065,182352 +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Layer 4 (Overlay) +//Storyboard Sound Samples + +[TimingPoints] +1433,324.324324324324,4,2,4,54,1,0 +1433,-135.135135135135,4,2,4,54,0,0 +6622,-135.135135135135,4,2,4,64,0,0 +10514,-135.135135135135,4,2,4,74,0,0 +11487,-227.272727272727,4,2,4,74,0,0 +11811,-87.719298245614,4,2,4,84,0,0 +21216,-135.135135135135,4,2,7,54,0,0 +22189,-135.135135135135,4,2,4,64,0,0 +32243,-69.4444444444444,4,2,4,74,0,0 +32568,-106.382978723404,4,2,4,74,0,0 +40676,-80.6451612903224,4,2,4,74,0,0 +43270,-156.25,4,2,4,74,0,0 +44081,-80.6451612903226,4,2,4,74,0,0 +45865,-156.25,4,2,4,74,0,0 +46676,-80.6451612903226,4,2,4,74,0,0 +48135,-119.047619047619,4,2,4,74,0,0 +56730,-80.6451612903226,4,2,4,74,0,0 +57865,-80.6451612903226,4,2,4,74,0,0 +58189,-227.272727272727,4,2,4,94,0,1 +58514,-87.719298245614,4,2,4,84,0,0 +68892,-87.719298245614,4,2,4,84,0,1 +74081,-119.047619047619,4,2,4,74,0,0 +76676,-87.719298245614,4,2,4,74,0,0 +81541,-69.4444444444444,4,2,4,74,0,0 +81865,-119.047619047619,4,2,4,74,0,0 +85757,-87.719298245614,4,2,4,74,0,0 +89324,-156.25,4,2,4,74,0,0 +92243,-119.047619047619,4,2,7,64,0,0 +96784,-119.047619047619,4,2,4,74,0,0 +100027,-96.1538461538461,4,2,4,74,0,0 +102297,-69.4444444444444,4,2,4,74,0,0 +102621,-106.382978723404,4,2,4,74,0,0 +110730,-80.6451612903224,4,2,4,74,0,0 +113324,-156.25,4,2,4,74,0,0 +114135,-80.6451612903226,4,2,4,74,0,0 +115919,-156.25,4,2,4,74,0,0 +116730,-80.6451612903226,4,2,4,74,0,0 +118189,-119.047619047619,4,2,4,74,0,0 +123378,-96.1538461538461,4,2,4,74,0,0 +126784,-80.6451612903226,4,2,4,74,0,0 +128243,-64.9350649350649,4,2,4,74,0,0 +128568,-80.6451612903226,4,2,4,90,0,1 +146730,-119.047619047619,4,2,4,80,0,0 +159703,-87.719298245614,4,2,4,80,0,0 +173973,-87.719298245614,4,2,4,90,0,0 +175270,-87.719298245614,4,2,4,84,0,0 +176081,-87.719298245614,4,2,4,84,0,0 +176405,-74.6268656716418,4,2,4,84,0,0 +177541,-227.272727272727,4,2,4,84,0,1 +177865,-227.272727272727,4,2,4,84,0,0 +182892,-227.272727272727,4,2,4,64,0,0 +183054,-156.25,4,2,4,64,0,0 +185649,-119.047619047619,4,2,4,74,0,0 +190838,-156.25,4,2,4,74,0,0 +191487,-74.6268656716418,4,2,4,90,0,1 +192135,-87.719298245614,4,2,4,84,0,0 +202514,-87.719298245614,4,2,4,84,0,1 +207703,-119.047619047619,4,2,4,74,0,0 +210297,-87.719298245614,4,2,4,74,0,0 +215162,-74.6268656716418,4,2,4,74,0,0 +215487,-87.719298245614,4,2,4,74,0,0 +224892,-156.25,4,2,4,54,0,0 + + +[Colours] +Combo1 : 89,244,183 +Combo2 : 249,188,87 +Combo3 : 108,197,249 +Combo4 : 197,137,137 + +[HitObjects] +493,304,1433,6,0,L|482:368,2,64.3800015717774,2|0|0,0:0|0:0|0:0,0:0:0:0: +442,244,1919,2,0,L|430:168,2,64.3800015717774,2|0|0,0:0|0:0|0:0,0:0:0:0: +394,304,2405,6,0,P|366:322|324:321,1,64.3800015717774,2|0,0:0|0:0,0:0:0:0: +277,272,2730,1,0,0:0:0:0: +183,169,2892,6,0,L|198:89,1,64.3800015717774,2|0,0:0|0:0,0:0:0:0: +257,66,3216,1,0,0:0:0:0: +178,178,3378,6,0,B|132:188|132:188|113:212|113:212|43:228,1,128.760003143555,2|2,0:0|0:0,0:0:0:0: +196,303,3865,1,0,3:2:0:0: +53,214,4027,6,0,P|125:192|187:285,2,193.140004715332,2|0|8,3:2|3:2|0:1,0:0:0:0: +194,105,5162,1,2,3:2:0:0: +31,0,5324,6,0,P|70:46|150:31,1,128.760003143555,2|0,3:2|0:0,0:0:0:0: +257,153,5811,1,0,3:2:0:0: +311,30,5973,5,8,0:0:0:0: +146,171,6135,1,0,3:2:0:0: +320,103,6297,5,8,3:0:0:0: +428,231,6460,1,8,3:0:0:0: +469,166,6622,6,0,P|421:150|328:258,1,193.140004715332,6|0,3:2|3:2,0:0:0:0: +234,379,7270,2,0,P|265:368|303:360,1,64.3800015717774,8|0,0:0|0:0,0:0:0:0: +155,291,7595,1,0,0:0:0:0: +334,244,7757,1,8,0:1:0:0: +334,244,7838,1,8,0:1:0:0: +334,244,7919,6,0,B|225:226|252:311|145:289,1,193.140004715332,2|0,3:2|0:0,0:0:0:0: +43,93,8568,1,8,0:0:0:0: +213,180,8730,1,0,3:2:0:0: +50,278,8892,1,8,3:1:0:0: +179,92,9054,1,0,3:2:0:0: +302,322,9216,5,4,3:2:0:0: +302,322,9541,5,10,0:1:0:0: +213,180,9703,1,8,0:1:0:0: +156,290,9865,5,8,0:1:0:0: +276,88,10027,1,8,0:1:0:0: +370,237,10189,5,8,0:0:0:0: +179,92,10351,1,8,0:0:0:0: +327,10,10514,5,10,0:0:0:0: +239,267,10676,1,8,0:0:0:0: +92,0,10838,5,8,0:0:0:0: +360,142,11000,1,8,0:0:0:0: +17,198,11162,5,4,3:2:0:0: +327,10,11324,1,8,0:0:0:0: +213,180,11487,6,0,P|204:155|207:111,1,57.4200017523194,6|0,0:2|0:0,0:0:0:0: +179,92,11811,6,0,B|283:67|257:165|365:142,1,198.359997578613,4|8,3:1|0:0,0:0:0:0: +161,247,12297,1,2,0:0:0:0: +333,351,12460,2,0,P|285:369|230:378,1,99.1799987893067,0|0,3:2|3:2,0:0:0:0: +359,234,12784,2,0,P|429:226|480:284,1,148.76999818396,8|0,0:0|0:0,0:0:0:0: +478,279,13108,6,0,P|469:327|466:383,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +262,177,13432,2,0,P|271:226|278:302,1,99.1799987893067,8|0,0:0|0:0,0:0:0:0: +462,114,13757,1,0,3:2:0:0: +330,16,13919,1,0,3:2:0:0: +424,194,14081,1,8,0:0:0:0: +500,26,14243,1,2,0:0:0:0: +500,26,14324,1,2,0:0:0:0: +500,26,14405,6,0,B|448:7|406:42|406:42|379:102|315:90,1,198.359997578613,4|8,3:2|0:0,0:0:0:0: +194,239,14892,1,2,0:0:0:0: +145,179,15054,2,0,P|157:127|163:69,1,99.1799987893067,0|0,3:2|3:2,0:0:0:0: +334,205,15378,2,0,P|340:274|354:364,1,148.76999818396,8|0,0:0|0:0,0:0:0:0: +280,384,15703,5,2,3:2:0:0: +428,267,15865,1,2,0:0:0:0: +267,152,16027,1,10,0:0:0:0: +435,350,16189,1,2,0:2:0:0: +512,117,16351,1,4,3:2:0:0: +317,254,16514,5,8,0:0:0:0: +274,268,16595,1,8,0:0:0:0: +231,240,16676,1,8,0:0:0:0: +225,184,16757,1,8,0:0:0:0: +267,152,16838,2,0,L|331:173,1,49.5899993946533,0|0,3:2|0:0,0:0:0:0: +163,356,17000,6,0,B|69:319|69:319|40:257|100:230,1,198.359997578613,6|8,3:2|0:0,0:0:0:0: +267,152,17487,1,2,0:0:0:0: +133,23,17649,2,0,L|152:133,1,99.1799987893067,0|0,3:2|3:2,0:0:0:0: +355,312,17973,2,0,B|385:297|413:289|413:289|398:248|385:191,1,148.76999818396,8|0,0:0|0:0,0:0:0:0: +345,146,18297,6,0,B|235:134|269:232|151:208,1,198.359997578613,2|8,3:2|0:0,0:0:0:0: +381,76,18784,1,0,0:0:0:0: +234,23,18946,5,0,3:2:0:0: +316,229,19108,1,0,3:2:0:0: +456,47,19270,5,8,0:0:0:0: +243,135,19432,1,8,0:1:0:0: +247,124,19514,1,8,0:1:0:0: +252,115,19595,6,0,B|285:125|285:125|294:133|294:133|359:149,1,99.1799987893067,6|2,3:2|0:0,0:0:0:0: +477,348,19919,1,10,0:0:0:0: +316,229,20081,1,2,0:0:0:0: +490,131,20243,5,10,0:0:0:0: +350,315,20405,1,0,3:2:0:0: +331,326,20487,1,0,3:2:0:0: +307,324,20568,1,0,3:2:0:0: +153,176,20730,1,2,3:2:0:0: +153,176,21054,5,2,0:0:0:0: +216,241,21216,2,0,P|202:299|195:379,1,128.760003143555,0|2,3:0|0:0,0:0:0:0: +69,222,21703,2,0,P|77:255|82:301,1,64.3800015717774,0|0,3:0|0:0,0:0:0:0: +109,82,22189,6,0,P|71:40|-4:55,2,128.760003143555,4|2|2,3:2|3:2|3:2,0:0:0:0: +172,127,23000,1,0,0:1:0:0: +277,0,23162,1,0,3:2:0:0: +209,33,23324,1,2,0:0:0:0: +330,148,23487,5,2,3:2:0:0: +379,92,23649,2,0,P|368:62|360:23,1,64.3800015717774,2|0,0:0|3:2,0:0:0:0: +230,185,23973,2,0,P|222:250|210:320,1,128.760003143555,2|0,0:0|0:0,0:0:0:0: +57,132,24460,2,0,P|64:163|68:201,1,64.3800015717774,2|0,3:2|0:0,0:0:0:0: +0,365,24784,6,0,P|39:322|114:339,1,128.760003143555,4|2,3:2|3:2,0:0:0:0: +352,146,25432,2,0,P|291:169|226:185,1,128.760003143555,2|0,3:2|3:2,0:0:0:0: +346,306,25919,1,2,0:0:0:0: +283,346,26081,5,2,3:2:0:0: +352,229,26243,2,0,P|382:243|424:253,1,64.3800015717774,2|0,0:0|3:2,0:0:0:0: +257,75,26568,2,0,P|215:127|234:188,1,128.760003143555,2|0,0:0|0:0,0:0:0:0: +401,73,27054,2,0,L|387:-4,1,64.3800015717774,10|2,0:1|0:0,0:0:0:0: +486,170,27378,5,12,0:0:0:0: +299,75,27541,1,8,0:1:0:0: +299,75,27622,1,8,0:1:0:0: +299,75,27703,1,8,0:1:0:0: +352,229,27865,5,8,0:0:0:0: +488,91,28027,1,8,0:1:0:0: +488,91,28108,1,8,0:1:0:0: +488,91,28189,1,8,0:1:0:0: +302,157,28351,5,10,0:0:0:0: +492,249,28514,1,2,0:0:0:0: +249,350,28676,5,8,3:2:0:0: +359,238,28838,1,8,0:1:0:0: +359,238,28919,1,8,0:1:0:0: +359,238,29000,1,8,0:1:0:0: +399,383,29162,5,2,3:2:0:0: +224,247,29324,1,8,0:1:0:0: +224,247,29405,1,8,0:1:0:0: +224,247,29487,1,8,0:1:0:0: +370,257,29649,5,0,3:2:0:0: +159,384,29811,1,2,0:0:0:0: +329,329,29973,5,8,0:0:0:0: +217,278,30135,1,2,0:0:0:0: +254,376,30297,5,0,3:2:0:0: +340,232,30460,1,0,3:2:0:0: +176,159,30622,5,8,0:0:0:0: +329,329,30784,1,8,0:0:0:0: +124,235,30946,5,8,0:0:0:0: +390,102,31108,1,8,0:0:0:0: +254,376,31270,5,4,3:2:0:0: +176,36,31432,1,0,3:2:0:0: +11,207,32081,5,10,0:1:0:0: +9,195,32162,1,10,0:1:0:0: +12,183,32243,2,0,B|48:137|107:154|107:154|127:207|190:209,1,187.920004587891,10|0,0:0|0:0,0:0:0:0: +230,258,32568,6,0,L|245:364,1,81.7799987521363,4|0,3:2|0:0,0:0:0:0: +412,228,32892,2,0,L|429:140,1,81.7799987521363,10|0,0:0|0:0,0:0:0:0: +289,55,33216,1,2,3:2:0:0: +307,126,33378,1,0,3:2:0:0: +420,29,33541,1,8,0:0:0:0: +356,0,33703,1,2,0:0:0:0: +228,103,33865,5,2,3:2:0:0: +169,156,34027,2,0,P|127:162|82:177,1,81.7799987521363,2|8,0:0|0:0,0:0:0:0: +225,321,34351,2,0,P|277:362|346:354,1,122.669998128204,2|0,3:2|0:0,0:0:0:0: +394,318,34676,1,0,3:2:0:0: +456,156,34838,1,10,0:0:0:0: +387,182,35000,1,0,0:0:0:0: +491,363,35162,6,0,P|499:323|500:275,1,81.7799987521363,4|0,3:2|0:0,0:0:0:0: +302,149,35487,2,0,P|304:187|316:237,1,81.7799987521363,8|0,0:0|0:0,0:0:0:0: +456,63,35811,1,0,3:2:0:0: +386,37,35973,1,0,3:2:0:0: +456,156,36135,1,8,0:0:0:0: +387,182,36297,1,2,0:0:0:0: +456,63,36460,5,2,3:2:0:0: +302,149,36622,2,0,P|259:145|225:106,1,81.7799987521363,2|8,0:0|0:0,0:0:0:0: +127,34,36946,2,0,P|113:108|104:216,1,163.559997504273,2|0,3:2|3:2,0:0:0:0: +229,33,37432,1,10,0:0:0:0: +0,156,37595,1,8,0:0:0:0: +0,156,37676,1,8,0:0:0:0: +0,156,37757,6,0,B|91:134|91:134|126:164|101:204,1,163.559997504273,4|8,3:2|0:0,0:0:0:0: +223,324,38243,2,0,P|186:309|134:297,1,81.7799987521363,0|0,0:0|3:2,0:0:0:0: +261,217,38568,1,0,3:2:0:0: +105,196,38730,5,8,0:0:0:0: +159,384,38892,1,2,0:0:0:0: +57,263,39054,6,0,P|52:304|43:350,1,81.7799987521363,0|0,3:2|0:0,0:0:0:0: +185,183,39378,1,8,0:0:0:0: +344,308,39541,2,0,P|330:225|327:135,1,163.559997504273,0|2,3:2|3:2,0:0:0:0: +223,324,40027,5,8,0:0:0:0: +436,193,40189,1,2,0:0:0:0: +244,93,40351,5,2,3:2:0:0: +421,288,40514,1,2,0:0:0:0: +421,288,40595,1,2,0:0:0:0: +421,288,40676,6,0,B|325:252|325:252|344:278|344:320,1,161.819993580139,14|0,0:0|0:0,0:0:0:0: +103,145,41000,6,0,B|230:105|230:105|190:141|202:192,1,215.759991440186,12|8,0:0|0:0,0:0:0:0: +418,20,41487,1,8,0:1:0:0: +418,20,41568,1,8,0:1:0:0: +418,20,41649,6,0,B|339:0|339:0|284:35|326:76,1,161.819993580139,4|0,3:2|0:0,0:0:0:0: +375,116,41973,1,8,0:0:0:0: +181,32,42135,6,0,B|144:24|144:24|132:11|132:11|67:-3,1,107.879995720093,4|8,3:2|0:1,0:0:0:0: +280,169,42460,1,0,3:2:0:0: +78,332,42622,6,0,B|53:248|53:248|139:221,1,161.819993580139,12|0,0:0|0:0,0:0:0:0: +189,264,42946,6,0,L|260:287,1,53.9399978600465,4|0,3:2|0:0,0:0:0:0: +381,384,43108,1,8,0:0:0:0: +444,339,43270,6,0,L|459:266,1,55.68,2|0,0:0|0:0,0:0:0:0: +280,169,43595,1,2,3:2:0:0: +401,169,43757,5,8,3:0:0:0: +240,280,43919,1,8,3:0:0:0: +401,169,44081,6,0,B|423:68|423:68|384:16|321:42,1,215.759991440186,6|2,3:2|3:2,0:0:0:0: +455,284,44568,1,10,0:0:0:0: +280,169,44730,1,0,3:2:0:0: +446,58,44892,1,8,0:0:0:0: +321,245,45054,1,0,3:2:0:0: +321,245,45135,1,0,3:2:0:0: +321,245,45216,5,8,0:2:0:0: +224,19,45378,1,0,3:2:0:0: +401,169,45541,5,4,3:2:0:0: +144,285,45703,1,8,3:2:0:0: +98,221,45865,6,0,L|110:160,1,55.68,2|8,0:0|3:0,0:0:0:0: +127,95,46108,1,8,3:0:0:0: +130,81,46189,1,2,3:2:0:0: +0,173,46351,1,0,3:2:0:0: +133,68,46514,5,8,0:0:0:0: +273,240,46676,2,0,P|228:294|130:271,1,161.819993580139,4|0,3:2|0:0,0:0:0:0: +98,221,47000,1,2,3:2:0:0: +278,94,47162,1,10,0:0:0:0: +216,52,47324,1,0,3:2:0:0: +325,319,47487,1,8,0:0:0:0: +193,209,47649,5,8,0:0:0:0: +195,195,47730,1,8,0:0:0:0: +198,180,47811,1,8,0:0:0:0: +356,70,47973,1,0,3:2:0:0: +424,119,48135,6,0,L|439:201,1,73.0799977697755,4|8,3:2|0:1,0:0:0:0: +452,263,48378,1,8,0:1:0:0: +455,283,48460,1,2,3:2:0:0: +338,197,48622,1,8,0:1:0:0: +235,343,48784,2,0,L|253:255,2,73.0799977697755,2|8|10,3:2|0:1|0:0,0:0:0:0: +56,207,49270,1,8,0:1:0:0: +56,207,49351,1,8,0:1:0:0: +56,207,49432,6,0,L|150:187,1,73.0799977697755,0|8,3:2|0:1,0:0:0:0: +198,180,49676,1,8,0:1:0:0: +222,175,49757,1,2,3:2:0:0: +344,84,49919,1,8,0:1:0:0: +239,6,50081,2,0,L|252:97,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +385,177,50405,1,10,0:0:0:0: +473,75,50568,1,8,0:1:0:0: +473,75,50649,1,8,0:1:0:0: +473,75,50730,6,0,L|462:171,1,73.0799977697755,0|8,3:2|0:1,0:0:0:0: +453,220,50973,1,8,0:1:0:0: +451,236,51054,1,2,3:2:0:0: +321,132,51216,1,8,0:1:0:0: +218,280,51378,2,0,L|294:262,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +198,180,51703,1,10,0:0:0:0: +297,272,51865,1,8,0:1:0:0: +297,272,51946,1,8,0:1:0:0: +297,272,52027,6,0,L|284:367,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +227,384,52270,1,8,0:1:0:0: +227,384,52351,1,0,3:2:0:0: +63,242,52514,1,10,0:0:0:0: +63,242,52595,1,8,0:1:0:0: +63,242,52676,5,8,3:1:0:0: +91,196,52757,1,8,0:1:0:0: +138,186,52838,1,8,0:1:0:0: +186,209,52919,1,8,0:1:0:0: +187,230,53000,5,10,3:2:0:0: +234,278,53081,1,8,0:0:0:0: +297,272,53162,1,8,0:0:0:0: +330,217,53243,1,8,0:0:0:0: +295,161,53324,5,4,3:2:0:0: +295,161,53649,5,2,3:2:0:0: +401,308,53811,1,2,0:0:0:0: +187,230,53973,5,10,3:1:0:0: +383,113,54135,1,8,0:1:0:0: +268,345,54297,5,8,0:0:0:0: +181,103,54460,1,8,0:1:0:0: +353,192,54622,5,8,0:0:0:0: +125,325,54784,1,8,0:0:0:0: +253,53,54946,5,8,0:0:0:0: +349,356,55108,1,8,0:0:0:0: +107,207,55270,5,8,3:2:0:0: +408,39,55432,1,8,0:0:0:0: +93,84,55595,5,8,0:0:0:0: +434,284,55757,1,8,0:0:0:0: +189,384,55919,5,8,0:2:0:0: +261,22,56081,1,6,3:2:0:0: +261,22,56730,5,2,3:2:0:0: +349,270,56892,1,8,0:0:0:0: +403,210,57054,6,0,P|363:166|254:178,1,161.819993580139,4|0,3:2|0:0,0:0:0:0: +192,141,57378,1,0,3:2:0:0: +0,284,57541,1,8,0:0:0:0: +94,384,57703,5,2,0:0:0:0: +11,180,57865,1,10,0:0:0:0: +11,180,57946,1,8,0:0:0:0: +11,180,58027,2,0,P|34:192|86:200,1,53.9399978600465,0|0,3:2|0:0,0:0:0:0: +227,293,58189,6,0,P|204:311|162:313,1,57.4200017523194,4|0,3:2|0:0,0:0:0:0: +117,261,58514,6,0,B|228:246|185:353|319:334,1,198.359997578613,6|8,3:1|0:0,0:0:0:0: +403,210,59000,1,2,0:0:0:0: +512,346,59162,6,0,P|466:323|411:315,1,99.1799987893067,2|0,3:2|3:2,0:0:0:0: +292,168,59487,1,10,0:0:0:0: +446,107,59649,1,2,0:0:0:0: +446,107,59730,1,2,0:0:0:0: +446,107,59811,6,0,P|388:179|280:158,1,198.359997578613,2|8,3:2|0:0,0:0:0:0: +117,261,60297,1,0,0:0:0:0: +38,79,60460,6,0,P|83:98|138:116,1,99.1799987893067,2|0,3:2|3:2,0:0:0:0: +249,284,60784,1,8,0:0:0:0: +51,366,60946,1,2,0:0:0:0: +51,366,61027,1,2,0:0:0:0: +51,366,61108,6,0,P|1:310|67:211,1,198.359997578613,2|8,3:2|0:0,0:0:0:0: +189,366,61595,1,2,0:0:0:0: +197,181,61757,5,2,3:2:0:0: +25,290,61919,1,0,3:2:0:0: +130,113,62081,5,10,0:0:0:0: +255,290,62243,1,2,0:0:0:0: +255,290,62324,1,2,0:0:0:0: +255,290,62405,6,0,B|303:301|303:301|326:330|326:330|407:347,1,148.76999818396,2|0,3:2|0:0,0:0:0:0: +448,294,62730,1,8,0:0:0:0: +266,384,62892,1,2,0:0:0:0: +330,157,63054,5,6,3:2:0:0: +464,369,63216,1,8,0:0:0:0: +476,372,63297,1,8,0:1:0:0: +486,376,63378,1,10,0:1:0:0: +340,244,63541,5,8,0:0:0:0: +340,244,63622,1,8,0:0:0:0: +340,244,63703,2,0,B|236:226|264:317|146:304,1,198.359997578613,6|8,3:2|0:0,0:0:0:0: +330,157,64189,1,0,0:0:0:0: +191,35,64351,2,0,P|230:66|292:58,1,99.1799987893067,2|0,3:2|3:2,0:0:0:0: +114,169,64676,1,8,0:0:0:0: +276,284,64838,1,2,0:0:0:0: +191,35,65000,6,0,L|205:148,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +69,328,65324,2,0,B|19:298|19:298|50:214,1,148.76999818396,8|0,0:0|0:0,0:0:0:0: +114,169,65649,1,0,3:2:0:0: +276,284,65811,1,0,3:2:0:0: +385,136,65973,2,0,P|333:150|277:177,1,99.1799987893067,10|0,0:0|0:0,0:0:0:0: +426,382,66297,6,0,P|477:343|445:224,1,198.359997578613,4|8,3:2|0:0,0:0:0:0: +277,65,66784,1,2,3:2:0:0: +466,140,66946,5,8,0:0:0:0: +276,284,67108,1,0,3:2:0:0: +370,44,67270,5,8,0:0:0:0: +449,226,67432,1,0,3:2:0:0: +218,127,67595,6,0,B|138:125|157:176|48:171,1,148.76999818396,4|0,3:2|0:0,0:0:0:0: +17,215,67919,1,8,0:0:0:0: +0,184,68000,1,8,0:0:0:0: +12,147,68081,1,8,0:0:0:0: +182,268,68243,1,2,3:2:0:0: +5,376,68405,1,0,3:2:0:0: +182,268,68568,6,0,P|242:258|311:316,1,148.76999818396,10|0,0:0|0:0,0:0:0:0: +352,379,68892,6,0,L|321:176,1,198.359997578613,6|8,3:1|0:0,0:0:0:0: +452,288,69378,1,0,3:2:0:0: +236,384,69541,5,8,0:0:0:0: +428,187,69703,1,0,3:2:0:0: +449,384,69865,5,10,0:0:0:0: +237,183,70027,1,2,3:2:0:0: +237,183,70108,1,2,3:2:0:0: +237,183,70189,6,0,B|249:103|249:103|281:79|320:113,1,148.76999818396,4|0,3:2|0:0,0:0:0:0: +302,117,70514,2,0,B|267:127|267:127|254:140|254:140|186:157,1,99.1799987893067,8|0,0:0|0:0,0:0:0:0: +335,2,70838,5,8,0:0:0:0: +436,195,71000,1,0,3:2:0:0: +436,195,71081,1,0,3:2:0:0: +436,195,71162,1,8,0:0:0:0: +311,106,71324,5,0,3:2:0:0: +224,322,71487,2,0,P|174:336|96:233,1,198.359997578613,6|8,3:2|0:0,0:0:0:0: +49,175,71973,1,2,0:0:0:0: +177,84,72135,5,10,0:0:0:0: +20,260,72297,1,0,3:2:0:0: +86,94,72460,5,10,0:0:0:0: +241,246,72622,1,0,3:2:0:0: +80,332,72784,6,0,L|99:213,1,99.1799987893067,6|2,3:2|0:0,0:0:0:0: +306,124,73108,1,8,0:0:0:0: +154,169,73270,5,0,3:2:0:0: +342,313,73432,1,4,3:2:0:0: +215,354,73595,5,2,3:2:0:0: +199,318,73676,1,8,0:0:0:0: +214,277,73757,1,10,0:0:0:0: +417,248,73919,5,8,0:0:0:0: +428,270,74000,1,8,0:0:0:0: +413,298,74081,2,0,P|375:303|336:315,1,73.0799977697755,6|0,3:2|0:0,0:0:0:0: +464,175,74405,1,0,0:0:0:0: +395,145,74568,1,0,3:2:0:0: +498,35,74730,6,0,L|508:117,1,73.0799977697755,8|2,0:0|0:0,0:0:0:0: +378,40,75054,1,10,0:1:0:0: +286,149,75216,1,10,0:1:0:0: +286,149,75297,1,8,0:1:0:0: +286,149,75378,6,0,P|293:117|303:57,1,73.0799977697755,2|0,3:2|0:0,0:0:0:0: +436,241,75703,2,0,P|401:252|363:267,1,73.0799977697755,0|2,3:2|0:0,0:0:0:0: +241,384,76027,5,8,0:0:0:0: +464,175,76270,5,2,0:0:0:0: +210,32,76514,5,2,0:0:0:0: +280,204,76676,6,0,B|300:87|300:87|276:124|228:132,1,198.359997578613,4|0,3:2|3:2,0:0:0:0: +367,265,77162,2,0,B|406:255|436:276|436:276|462:321|522:321,1,148.76999818396,8|0,0:0|0:0,0:0:0:0: +509,247,77487,1,8,0:0:0:0: +509,247,77568,1,8,0:0:0:0: +509,247,77649,6,0,P|497:169|486:81,1,148.76999818396,4|0,3:2|0:0,0:0:0:0: +428,51,77973,1,0,3:2:0:0: +280,204,78135,6,0,P|291:129|298:47,1,148.76999818396,8|0,0:0|0:0,0:0:0:0: +353,6,78460,1,10,0:0:0:0: +175,118,78622,1,10,0:0:0:0: +367,265,78784,1,8,0:0:0:0: +301,135,78946,1,0,3:2:0:0: +139,236,79108,1,8,0:1:0:0: +139,236,79189,1,8,0:1:0:0: +139,236,79270,6,0,B|182:250|182:250|203:287|203:287|272:307,1,148.76999818396,4|0,3:2|0:0,0:0:0:0: +330,335,79595,1,10,0:1:0:0: +231,178,79757,1,2,0:0:0:0: +391,241,79919,5,8,0:0:0:0: +228,375,80081,1,8,0:1:0:0: +311,114,80243,5,8,0:0:0:0: +442,339,80405,1,8,0:0:0:0: +386,52,80568,5,4,3:2:0:0: +327,359,80730,1,4,3:2:0:0: +62,133,81541,6,0,P|114:64|218:88,1,187.920004587891,4|0,3:2|0:0,0:0:0:0: +256,133,81865,6,0,P|247:169|236:217,1,73.0799977697755,4|0,3:1|0:0,0:0:0:0: +399,329,82189,2,0,P|394:293|391:236,1,73.0799977697755,0|0,3:2|0:0,0:0:0:0: +203,313,82514,6,0,P|238:323|279:341,1,73.0799977697755,0|0,3:2|0:0,0:0:0:0: +406,168,82838,2,0,P|371:178|330:197,1,73.0799977697755,0|0,3:2|0:0,0:0:0:0: +209,56,83162,6,0,P|268:41|332:111,1,146.159995539551,2|2,3:2|3:2,0:0:0:0: +239,204,83649,1,2,0:0:0:0: +328,92,83811,2,0,P|362:82|409:58,1,73.0799977697755,2|0,3:2|0:0,0:0:0:0: +507,193,84135,2,0,P|472:185|430:164,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +274,337,84460,6,0,B|346:308|346:308|405:324|381:362,1,146.159995539551,4|0,3:2|3:2,0:0:0:0: +239,204,84946,1,2,0:0:0:0: +186,260,85108,2,0,L|100:282,1,73.0799977697755,0|0,3:2|0:0,0:0:0:0: +281,136,85432,2,0,L|365:156,1,73.0799977697755,0|0,3:2|0:0,0:0:0:0: +161,5,85757,5,6,3:2:0:0: +239,204,85919,1,2,0:0:0:0: +273,44,86081,5,10,0:1:0:0: +124,190,86243,2,0,B|226:178|199:272|321:253,1,198.359997578613,4|0,3:2|3:2,0:0:0:0: +161,5,86730,1,8,0:0:0:0: +138,37,86811,1,8,0:1:0:0: +147,70,86892,1,0,3:2:0:0: +352,152,87054,6,0,P|421:237|391:332,1,198.359997578613,2|0,3:2|3:2,0:0:0:0: +239,204,87541,1,0,0:0:0:0: +183,253,87703,2,0,P|194:300|201:363,1,99.1799987893067,0|0,3:2|0:0,0:0:0:0: +307,256,88027,1,0,3:2:0:0: +193,142,88189,5,8,0:0:0:0: +307,256,88351,2,0,B|201:239|236:343|86:313,1,198.359997578613,2|0,3:2|3:2,0:0:0:0: +323,165,88838,1,0,0:0:0:0: +171,32,89000,2,0,P|206:60|274:60,1,99.1799987893067,0|8,3:2|0:0,0:0:0:0: +166,153,89324,6,0,L|69:176,1,83.52,4|0,3:0|0:0,0:0:0:0: +54,183,89649,5,4,3:2:0:0: +282,51,89811,1,2,0:0:0:0: +153,20,89973,5,10,0:0:0:0: +339,174,90135,1,0,3:2:0:0: +126,318,90297,1,0,3:2:0:0: +238,125,90460,1,0,3:2:0:0: +242,141,90541,1,0,3:2:0:0: +244,158,90622,1,0,3:2:0:0: +338,281,90784,6,0,B|374:293|374:293|388:309|388:309|454:328,1,111.36,4|0,3:2|3:2,0:0:0:0: +481,138,91270,2,0,L|403:158,1,55.68,8|0,0:0|0:0,0:0:0:0: +356,170,91514,1,8,3:0:0:0: +339,174,91595,1,0,3:2:0:0: +463,235,91757,1,8,3:0:0:0: +373,95,91919,2,0,P|378:53|392:6,1,83.52,8|0,3:0|0:0,0:0:0:0: +389,13,92243,6,0,P|354:57|267:49,1,146.159995539551,4|2,1:2|3:2,0:0:0:0: +126,207,92892,2,0,P|160:196|206:186,1,73.0799977697755,2|0,3:2|0:3,0:3:0:0: +75,40,93216,2,0,P|111:51|155:59,1,73.0799977697755,2|2,3:2|0:0,0:0:0:0: +42,128,93541,5,2,1:2:0:0: +126,207,93703,2,0,L|102:355,1,146.159995539551,2|2,0:0|0:0,0:0:0:0: +260,235,94189,2,0,L|272:312,1,73.0799977697755,2|0,3:2|0:0,0:0:0:0: +404,168,94514,1,2,3:2:0:0: +329,138,94676,1,0,1:2:0:0: +389,280,94838,6,0,B|467:265|433:222|519:208,1,146.159995539551,2|2,1:2|3:2,0:0:0:0: +512,209,95487,6,0,P|477:201|434:180,1,73.0799977697755,2|0,3:2|0:0,0:0:0:0: +306,51,95811,2,0,P|341:45|387:22,1,73.0799977697755,0|2,3:2|1:2,0:0:0:0: +265,196,96135,5,2,1:2:0:0: +169,81,96297,2,0,P|202:96|255:108,1,73.0799977697755,2|0,0:0|3:2,0:0:0:0: +106,191,96622,1,2,1:2:0:0: +220,333,96784,2,0,L|208:237,2,73.0799977697755,10|0|0,0:0|3:2|3:2,0:0:0:0: +208,345,97432,5,4,3:2:0:0: +373,258,97595,1,8,0:1:0:0: +373,258,97676,1,8,0:1:0:0: +373,258,97757,1,0,3:2:0:0: +286,206,97919,5,8,0:0:0:0: +404,345,98081,1,8,3:1:0:0: +404,345,98162,1,8,0:1:0:0: +404,345,98243,1,8,0:1:0:0: +295,285,98405,5,10,0:0:0:0: +455,191,98568,1,8,0:1:0:0: +455,191,98649,1,8,0:1:0:0: +455,191,98730,6,0,L|442:98,1,73.0799977697755,8|8,0:0|0:1,0:0:0:0: +433,49,98973,1,8,0:1:0:0: +430,33,99054,1,0,3:2:0:0: +322,120,99216,1,8,0:1:0:0: +236,48,99378,1,0,3:2:0:0: +237,36,99460,1,8,0:1:0:0: +239,20,99541,1,8,0:1:0:0: +322,120,99703,5,8,3:2:0:0: +166,190,99865,1,8,0:1:0:0: +165,177,99946,1,8,0:1:0:0: +163,166,100027,6,0,B|195:129|243:146|243:146|267:202|319:200,1,180.959998895508,4|8,3:2|0:1,0:0:0:0: +136,71,100514,1,8,0:1:0:0: +322,120,100676,5,10,0:0:0:0: +119,280,100838,1,8,0:1:0:0: +236,48,101000,5,8,0:0:0:0: +346,315,101162,1,8,0:0:0:0: +47,167,101324,5,4,3:2:0:0: +384,47,101487,1,4,3:2:0:0: +392,53,102135,5,8,0:1:0:0: +392,53,102216,5,8,0:1:0:0: +392,53,102297,6,0,B|325:31|325:31|270:44|248:110|311:129,1,187.920004587891,8|0,0:0|0:0,0:0:0:0: +229,189,102622,6,0,B|322:178|287:244|401:227,1,163.559997504273,4|10,3:2|0:0,0:0:0:0: +438,175,103108,1,0,0:0:0:0: +285,287,103270,2,0,L|196:302,1,81.7799987521363,2|0,3:2|3:2,0:0:0:0: +343,378,103595,1,8,0:0:0:0: +363,307,103757,1,2,0:0:0:0: +194,121,103919,5,2,3:2:0:0: +229,189,104081,2,0,L|309:206,1,81.7799987521363,2|8,0:0|0:0,0:0:0:0: +146,366,104405,2,0,P|108:332|134:259,1,122.669998128204,2|0,3:2|0:0,0:0:0:0: +95,201,104730,1,0,3:2:0:0: +272,122,104892,2,0,P|305:79|270:9,1,122.669998128204,8|0,0:0|0:0,0:0:0:0: +214,1,105216,6,0,B|117:-5|149:53|50:41,1,163.559997504273,4|10,3:2|0:0,0:0:0:0: +194,121,105703,1,0,0:0:0:0: +95,201,105865,2,0,L|112:103,1,81.7799987521363,2|2,3:2|3:2,0:0:0:0: +279,219,106189,2,0,L|295:356,1,122.669998128204,8|0,0:0|0:0,0:0:0:0: +231,382,106514,6,0,P|200:355|210:313,1,81.7799987521363,2|0,3:2|0:0,0:0:0:0: +369,185,106838,2,0,L|461:213,1,81.7799987521363,10|2,0:0|3:2,0:0:0:0: +310,88,107162,1,2,0:0:0:0: +300,16,107324,1,0,3:2:0:0: +194,121,107487,1,8,0:0:0:0: +378,195,107649,1,8,0:1:0:0: +378,195,107730,1,8,0:1:0:0: +378,195,107811,6,0,B|289:190|315:259|208:247,1,163.559997504273,2|8,3:2|0:0,0:0:0:0: +122,87,108297,1,0,0:0:0:0: +194,121,108460,1,0,3:2:0:0: +48,214,108622,1,0,3:2:0:0: +37,137,108784,1,10,0:0:0:0: +144,208,108946,1,8,0:1:0:0: +144,208,109027,1,8,0:1:0:0: +144,208,109108,6,0,L|121:352,1,122.669998128204,0|0,3:2|0:0,0:0:0:0: +58,361,109432,1,8,0:0:0:0: +244,241,109595,6,0,P|327:236|374:308,1,163.559997504273,0|2,3:2|3:2,0:0:0:0: +194,121,110081,1,10,0:0:0:0: +354,43,110243,1,2,0:0:0:0: +290,0,110405,5,0,3:2:0:0: +402,132,110568,1,8,3:1:0:0: +402,132,110649,1,8,0:1:0:0: +402,132,110730,6,0,B|301:107|301:107|319:135|323:171,1,161.819993580139,4|0,3:2|0:0,0:0:0:0: +117,295,111054,2,0,B|157:241|217:263|217:263|246:327|317:322,1,215.759991440186,4|8,3:2|0:0,0:0:0:0: +109,164,111541,1,8,0:1:0:0: +109,164,111622,1,8,0:1:0:0: +109,164,111703,5,2,3:2:0:0: +279,95,111865,1,0,3:2:0:0: +99,38,112027,5,8,0:0:0:0: +216,244,112189,2,0,L|194:71,1,161.819993580139,4|0,3:2|0:0,0:0:0:0: +245,28,112514,1,0,3:2:0:0: +23,186,112676,1,8,0:0:0:0: +179,352,112838,1,8,3:1:0:0: +159,363,112919,1,8,3:1:0:0: +138,355,113000,6,0,L|153:288,1,53.9399978600465,0|0,3:2|0:0,0:0:0:0: +331,171,113162,1,4,3:2:0:0: +397,215,113324,6,0,L|408:278,1,55.68,2|0,0:0|0:0,0:0:0:0: +252,149,113649,1,0,3:2:0:0: +411,51,113811,1,8,3:0:0:0: +347,16,113973,1,0,3:2:0:0: +457,161,114135,6,0,B|382:174|382:174|349:227|349:227|265:242,1,215.759991440186,6|2,3:2|3:2,0:0:0:0: +126,120,114622,1,10,0:0:0:0: +267,55,114784,1,0,3:2:0:0: +130,257,114946,5,10,0:0:0:0: +179,36,115108,1,0,3:2:0:0: +179,36,115189,1,0,3:2:0:0: +179,36,115270,5,8,0:0:0:0: +353,157,115432,1,0,3:2:0:0: +130,257,115595,5,8,0:0:0:0: +353,74,115757,1,12,0:0:0:0: +259,0,115919,2,0,L|270:73,1,55.68,2|8,0:0|0:3,0:0:0:0: +280,124,116162,1,8,0:3:0:0: +283,135,116243,5,0,3:2:0:0: +441,224,116405,1,0,3:2:0:0: +353,74,116568,5,8,0:0:0:0: +203,217,116730,6,0,B|264:204|281:151|281:151|268:218|308:262,1,215.759991440186,4|2,3:2|3:2,0:0:0:0: +180,111,117216,2,0,L|62:85,1,107.879995720093,10|0,0:0|3:2,0:0:0:0: +241,8,117541,1,10,0:0:0:0: +76,228,117703,1,8,0:1:0:0: +80,217,117784,1,8,0:1:0:0: +85,205,117865,2,0,L|146:222,1,53.9399978600465,8|0,0:1|0:0,0:0:0:0: +334,361,118027,1,8,0:1:0:0: +328,344,118108,1,8,0:1:0:0: +321,329,118189,6,0,L|234:345,1,73.0799977697755,4|8,3:2|0:1,0:0:0:0: +180,358,118432,1,8,0:1:0:0: +164,362,118514,1,2,3:2:0:0: +301,253,118676,1,8,0:1:0:0: +407,282,118838,6,0,L|506:306,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +321,172,119162,1,2,3:1:0:0: +445,94,119324,1,8,0:1:0:0: +431,98,119405,1,8,0:1:0:0: +416,103,119487,5,0,3:2:0:0: +316,12,119649,1,8,0:1:0:0: +331,16,119730,1,8,0:1:0:0: +342,22,119811,2,0,L|323:104,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +194,229,120135,1,10,3:1:0:0: +150,214,120216,1,8,0:1:0:0: +132,178,120297,1,8,0:1:0:0: +143,139,120378,1,8,0:1:0:0: +175,120,120460,5,10,3:1:0:0: +331,273,120622,1,8,0:1:0:0: +331,273,120703,1,8,0:1:0:0: +331,273,120784,6,0,L|251:293,1,73.0799977697755,0|8,3:2|0:1,0:0:0:0: +189,306,121027,1,8,0:1:0:0: +172,311,121108,1,2,3:2:0:0: +278,193,121270,1,8,0:0:0:0: +175,120,121432,5,10,3:1:0:0: +174,109,121514,1,8,0:1:0:0: +173,98,121595,1,8,0:1:0:0: +276,0,121757,2,0,L|262:81,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +315,126,122000,1,8,0:1:0:0: +315,126,122081,5,10,3:1:0:0: +177,210,122243,1,8,0:1:0:0: +162,214,122324,1,8,0:1:0:0: +150,217,122405,1,8,3:1:0:0: +260,290,122568,1,10,0:0:0:0: +312,208,122730,5,8,3:1:0:0: +346,188,122811,1,8,0:1:0:0: +384,199,122892,1,8,0:1:0:0: +406,233,122973,1,8,0:1:0:0: +391,250,123054,5,10,3:2:0:0: +413,304,123135,1,8,0:0:0:0: +474,312,123216,1,8,0:0:0:0: +512,266,123297,1,8,0:0:0:0: +512,251,123378,6,0,L|494:124,1,90.4799994477539,4|0,3:2|0:0,0:0:0:0: +260,290,123703,5,10,0:0:0:0: +324,73,123865,1,0,3:2:0:0: +413,304,124027,5,10,0:0:0:0: +222,147,124189,1,2,3:2:0:0: +437,36,124351,1,10,0:0:0:0: +346,188,124514,1,0,3:2:0:0: +192,21,124676,6,0,L|85:-5,1,90.4799994477539,8|0,0:0|0:0,0:0:0:0: +222,147,125000,5,10,0:0:0:0: +22,215,125162,1,0,3:2:0:0: +104,0,125324,5,8,0:0:0:0: +240,244,125487,1,0,3:2:0:0: +238,231,125568,1,0,3:2:0:0: +235,218,125649,1,8,0:0:0:0: +59,133,125811,1,8,0:1:0:0: +62,122,125892,1,8,0:1:0:0: +64,111,125973,5,4,3:2:0:0: +22,215,126135,1,0,3:2:0:0: +157,96,126297,5,0,3:2:0:0: +104,270,126460,1,0,3:2:0:0: +241,72,126622,5,0,3:2:0:0: +198,320,126784,1,0,3:2:0:0: +330,46,126946,5,10,0:0:0:0: +294,371,127108,1,8,0:0:0:0: +436,24,127270,5,8,3:2:0:0: +128,184,127432,1,8,0:0:0:0: +446,344,127595,1,8,0:0:0:0: +266,0,127757,1,8,0:0:0:0: +152,384,127919,1,8,0:0:0:0: +512,170,128081,1,0,3:2:0:0: +266,0,128243,6,0,L|280:152,1,133.979997342316,6|0,3:2|0:0,0:0:0:0: +378,196,128487,5,2,0:0:0:0: +396,176,128568,6,0,B|357:124|293:152|293:152|277:210|204:207,1,215.759991440186,6|8,3:1|0:0,0:0:0:0: +365,56,129054,1,2,0:0:0:0: +312,299,129216,5,0,3:2:0:0: +196,119,129378,1,0,3:2:0:0: +396,176,129541,1,8,0:0:0:0: +208,302,129703,1,2,0:0:0:0: +298,190,129865,6,0,B|410:171|384:285|519:260,1,215.759991440186,2|8,3:2|0:0,0:0:0:0: +281,90,130351,1,2,0:0:0:0: +484,174,130514,5,0,3:2:0:0: +312,299,130676,1,0,3:2:0:0: +504,380,130838,5,8,0:0:0:0: +307,198,131000,1,2,0:0:0:0: +307,198,131081,1,2,0:0:0:0: +307,198,131162,6,0,B|264:187|228:210|228:210|207:260|137:254,1,161.819993580139,4|0,3:2|0:0,0:0:0:0: +110,300,131487,1,8,0:0:0:0: +0,176,131649,1,2,0:0:0:0: +144,107,131811,5,0,3:2:0:0: +3,282,131973,1,0,3:2:0:0: +212,186,132135,5,8,0:0:0:0: +77,31,132297,1,2,0:0:0:0: +212,186,132460,6,0,P|255:216|323:195,1,107.879995720093,2|2,3:2|0:0,0:0:0:0: +455,384,132784,1,10,0:0:0:0: +297,290,132946,1,2,0:0:0:0: +430,145,133108,5,4,3:2:0:0: +339,362,133270,1,0,3:2:0:0: +512,239,133432,1,8,0:0:0:0: +344,92,133595,1,8,0:0:0:0: +344,92,133676,1,8,0:0:0:0: +344,92,133757,6,0,L|367:257,1,161.819993580139,2|0,3:2|0:0,0:0:0:0: +297,290,134081,1,10,0:0:0:0: +430,145,134243,1,2,0:0:0:0: +277,8,134405,2,0,P|241:52|262:114,1,107.879995720093,0|2,3:2|3:2,0:0:0:0: +442,257,134730,1,8,0:0:0:0: +344,92,134892,5,2,0:0:0:0: +205,225,135054,2,0,P|151:234|87:254,1,107.879995720093,2|2,3:2|0:0,0:0:0:0: +268,363,135378,1,10,0:0:0:0: +91,241,135541,2,0,P|67:170|170:105,1,215.759991440186,2|2,0:0|3:2,0:0:0:0: +331,242,136027,1,8,0:0:0:0: +128,372,136189,1,2,0:0:0:0: +223,299,136351,6,0,B|278:315|278:315|303:353|303:353|396:375,1,161.819993580139,4|0,3:2|0:0,0:0:0:0: +433,383,136676,1,10,0:0:0:0: +257,167,136838,1,0,3:2:0:0: +491,120,137000,5,8,0:0:0:0: +338,248,137162,1,0,3:2:0:0: +338,248,137243,1,0,3:2:0:0: +338,248,137324,1,8,0:0:0:0: +198,103,137487,1,0,3:2:0:0: +435,212,137649,5,10,0:0:0:0: +208,351,137811,2,0,L|223:296,1,53.9399978600465,10|0,0:0|0:0,0:0:0:0: +69,173,137973,2,0,L|85:251,2,53.9399978600465,10|8|10,0:0|0:0|0:1,0:0:0:0: +208,50,138297,6,0,L|194:124,1,53.9399978600465,10|0,3:2|0:0,0:0:0:0: +46,24,138460,2,0,P|70:12|105:2,1,53.9399978600465,8|0,3:0|0:0,0:0:0:0: +260,205,138622,2,0,P|236:193|194:181,1,53.9399978600465,10|0,0:0|0:0,0:0:0:0: +343,15,138784,2,0,P|356:46|347:88,1,53.9399978600465,2|0,0:0|0:0,0:0:0:0: +210,184,138946,6,0,B|328:154|328:154|384:193|355:253,1,215.759991440186,6|10,3:1|0:0,0:0:0:0: +512,353,139432,1,0,0:0:0:0: +435,378,139595,2,0,L|458:259,1,107.879995720093,0|0,3:2|3:2,0:0:0:0: +274,125,139919,2,0,B|287:182|287:182|269:223|269:223|282:288,1,161.819993580139,10|0,0:0|0:0,0:0:0:0: +289,361,140243,6,0,B|310:330|349:326|349:326|206:293,1,215.759991440186,4|8,3:2|0:0,0:0:0:0: +387,151,140730,1,2,0:0:0:0: +187,95,140892,5,2,3:2:0:0: +368,321,141054,1,2,3:2:0:0: +287,41,141216,1,10,0:0:0:0: +101,196,141378,1,2,0:0:0:0: +308,124,141541,5,2,3:2:0:0: +107,7,141703,1,2,3:2:0:0: +226,219,141865,5,8,0:0:0:0: +374,40,142027,2,0,P|440:36|504:166,1,215.759991440186,2|2,0:0|3:2,0:0:0:0: +287,41,142514,2,0,P|235:31|168:15,1,107.879995720093,8|0,0:0|0:0,0:0:0:0: +29,243,142838,6,0,B|78:249|106:205|92:210|120:161|186:179,1,161.819993580139,2|0,3:2|0:0,0:0:0:0: +226,219,143162,1,8,0:0:0:0: +364,348,143324,1,2,0:0:0:0: +364,348,143405,1,2,0:0:0:0: +364,348,143487,6,0,L|291:364,1,53.9399978600465,4|0,3:2|0:0,0:0:0:0: +429,222,143649,2,0,L|493:240,1,53.9399978600465,0|0,3:2|0:0,0:0:0:0: +344,142,143811,1,8,3:2:0:0: +313,113,143892,1,8,0:0:0:0: +311,72,143973,1,8,0:0:0:0: +342,40,144054,1,8,0:0:0:0: +403,74,144135,6,0,B|283:53|324:157|181:128,1,215.759991440186,4|8,3:2|0:0,0:0:0:0: +365,267,144622,1,2,0:0:0:0: +232,48,144784,1,10,0:0:0:0: +106,218,144946,1,0,3:2:0:0: +313,113,145108,1,8,0:0:0:0: +107,0,145270,1,2,0:0:0:0: +107,0,145351,1,2,0:0:0:0: +107,0,145432,6,0,P|117:51|123:117,1,107.879995720093,4|0,3:2|3:2,0:0:0:0: +325,27,145757,1,8,0:0:0:0: +256,238,145919,1,2,3:2:0:0: +149,107,146081,5,10,0:0:0:0: +368,228,146243,1,0,3:2:0:0: +120,384,146405,5,10,0:0:0:0: +329,316,146568,1,0,3:2:0:0: +149,107,146730,6,0,L|169:277,1,146.159995539551,6|2,3:2|0:0,0:0:0:0: +113,306,147216,1,2,3:2:0:0: +318,147,147378,5,8,0:0:0:0: +149,71,147541,1,2,0:0:0:0: +149,71,147703,1,2,0:0:0:0: +246,15,147865,1,2,0:0:0:0: +199,171,148027,6,0,P|163:179|113:197,1,73.0799977697755,2|0,3:2|0:0,0:0:0:0: +256,101,148351,1,2,0:0:0:0: +356,225,148514,1,0,3:2:0:0: +424,185,148676,2,0,P|433:151|436:88,1,73.0799977697755,8|0,0:0|0:0,0:0:0:0: +270,245,149000,2,0,P|274:288|288:326,1,73.0799977697755 +360,339,149324,6,0,B|275:331|301:385|187:375,1,146.159995539551,4|2,3:2|3:2,0:0:0:0: +356,225,149811,1,2,3:2:0:0: +168,96,149973,5,8,0:0:0:0: +360,339,150216,5,2,0:0:0:0: +242,25,150460,5,2,0:0:0:0: +86,185,150622,6,0,P|49:183|16:146,2,73.0799977697755,2|0|2,3:2|0:0|0:0,0:0:0:0: +140,240,151108,1,0,3:2:0:0: +256,131,151270,2,0,L|355:108,1,73.0799977697755,10|0,0:0|0:0,0:0:0:0: +399,95,151514,1,8,0:1:0:0: +419,89,151595,1,2,3:2:0:0: +319,36,151757,1,2,0:0:0:0: +416,229,151919,5,6,3:2:0:0: +489,32,152081,1,0,3:2:0:0: +327,114,152243,5,0,3:2:0:0: +331,126,152568,1,0,3:2:0:0: +335,138,152892,1,2,3:2:0:0: +488,256,153216,5,0,3:2:0:0: +488,244,153541,1,0,3:2:0:0: +489,228,153865,5,0,3:2:0:0: +315,330,154027,1,8,0:0:0:0: +426,384,154189,1,2,3:2:0:0: +336,233,154351,1,2,0:0:0:0: +336,233,154432,1,2,0:0:0:0: +336,233,154514,5,4,3:2:0:0: +137,361,154676,1,0,3:2:0:0: +202,170,154838,5,0,3:2:0:0: +205,190,155162,1,0,3:2:0:0: +208,209,155487,1,0,3:2:0:0: +80,122,155811,5,0,3:2:0:0: +230,48,155973,1,0,3:2:0:0: +61,0,156135,5,8,0:0:0:0: +193,148,156297,1,8,0:1:0:0: +217,158,156378,1,8,0:1:0:0: +244,152,156460,1,2,3:2:0:0: +120,246,156622,1,0,3:2:0:0: +294,99,156784,1,8,3:1:0:0: +318,82,156865,1,8,0:1:0:0: +351,87,156946,1,8,0:1:0:0: +428,207,157108,5,4,3:2:0:0: +230,48,157270,1,0,3:2:0:0: +120,246,157432,5,0,3:2:0:0: +122,229,157757,1,0,3:2:0:0: +124,213,158081,5,2,3:2:0:0: +295,314,158243,1,10,0:0:0:0: +122,384,158405,5,4,3:2:0:0: +324,222,158568,1,0,3:2:0:0: +428,368,158730,2,0,L|352:344,1,73.0799977697755,2|0,3:2|0:0,0:0:0:0: +380,162,159054,5,8,3:0:0:0: +242,215,159216,1,8,3:0:0:0: +428,368,159378,6,0,L|450:246,1,109.619996654663,10|0,0:0|0:0,0:0:0:0: +380,162,159703,6,0,B|379:125|363:88|363:88|385:53|435:69,1,148.76999818396,4|0,3:2|0:0,0:0:0:0: +484,116,160027,1,10,0:0:0:0: +242,215,160189,6,0,B|196:192|139:188|139:188|98:137|156:99,1,198.359997578613,4|0,3:2|3:2,0:0:0:0: +327,235,160676,1,8,0:0:0:0: +175,377,160838,1,2,0:0:0:0: +175,377,160919,1,2,0:0:0:0: +175,377,161000,6,0,P|145:339|161:276,1,99.1799987893067,2|0,3:2|3:2,0:0:0:0: +330,156,161324,1,8,0:0:0:0: +471,341,161487,1,8,0:1:0:0: +434,364,161568,1,8,0:1:0:0: +399,355,161649,5,10,0:0:0:0: +328,258,161811,1,0,3:2:0:0: +474,149,161973,5,10,0:0:0:0: +466,186,162054,1,8,0:0:0:0: +458,219,162135,1,8,0:0:0:0: +363,82,162297,6,0,B|306:61|268:101|268:101|247:159|186:148,1,198.359997578613,4|8,3:2|0:0,0:0:0:0: +139,96,162784,1,0,0:0:0:0: +35,225,162946,5,2,0:0:0:0: +60,152,163108,1,0,3:2:0:0: +223,298,163270,1,8,0:0:0:0: +210,223,163432,1,2,0:0:0:0: +45,321,163595,6,0,P|91:334|146:360,1,99.1799987893067,0|0,3:2|0:0,0:0:0:0: +287,246,163919,1,8,0:0:0:0: +139,96,164081,1,2,0:0:0:0: +287,246,164243,6,0,P|333:237|393:212,1,99.1799987893067,2|0,0:0|3:2,0:0:0:0: +228,64,164568,2,0,P|167:119|184:185,1,148.76999818396,8|0,0:0|0:0,0:0:0:0: +187,251,164892,5,4,3:2:0:0: +362,95,165054,1,0,3:2:0:0: +178,177,165216,5,8,0:0:0:0: +393,331,165378,2,0,P|416:285|393:224,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +224,352,165703,2,0,P|174:360|108:380,1,99.1799987893067,2|8,3:2|0:0,0:0:0:0: +38,139,166027,6,0,B|90:140|125:108|125:108|80:150|98:217,1,198.359997578613,2|2,3:2|3:2,0:0:0:0: +224,352,166514,1,8,0:0:0:0: +320,219,166676,1,2,0:0:0:0: +320,219,166757,1,2,0:0:0:0: +320,219,166838,2,0,L|337:325,1,99.1799987893067,0|0,3:2|3:2,0:0:0:0: +208,168,167162,2,0,P|239:110|319:114,1,148.76999818396,8|8,0:0|0:1,0:0:0:0: +387,85,167487,5,4,3:2:0:0: +387,85,167811,6,0,P|393:133|403:185,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +247,0,168135,2,0,P|231:46|228:99,1,99.1799987893067,0|0,3:2|0:0,0:0:0:0: +51,253,168460,2,0,P|86:198|169:220,1,148.76999818396,2|0,3:2|0:0,0:0:0:0: +184,282,168784,6,0,P|232:292|284:317,1,99.1799987893067,10|0,3:1|0:0,0:0:0:0: +402,182,169108,2,0,P|354:191|293:217,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +414,367,169432,2,0,P|440:323|419:267,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +223,116,169757,2,0,P|230:165|234:241,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +488,67,170081,6,0,P|450:22|352:52,1,148.76999818396,10|0,3:1|0:0,0:0:0:0: +319,92,170405,2,0,P|270:101|204:123,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +393,237,170730,2,0,P|439:248|503:272,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +308,384,171054,2,0,P|280:339|306:287,1,99.1799987893067,2|0,3:2|0:0,0:0:0:0: +195,44,171378,6,0,L|173:230,1,148.76999818396,2|0,3:2|0:0,0:0:0:0: +230,247,171703,1,0,3:2:0:0: +31,366,171865,2,0,B|7:268|7:268|99:234,1,198.359997578613,2|2,0:0|0:0,0:0:0:0: +204,62,172351,6,0,B|147:52|147:52|124:22|124:22|52:13,1,148.76999818396,10|0,0:0|0:0,0:0:0:0: +14,62,172676,5,4,3:2:0:0: +161,175,172838,1,0,3:2:0:0: +35,149,173000,5,0,3:2:0:0: +204,62,173162,1,0,3:2:0:0: +262,266,173324,5,8,3:1:0:0: +124,91,173487,1,8,3:1:0:0: +123,310,173649,5,8,3:1:0:0: +265,107,173811,1,8,3:1:0:0: +319,381,173973,5,8,3:2:0:0: +161,175,174135,1,8,3:2:0:0: +363,258,174297,1,8,3:2:0:0: +155,384,174460,1,8,3:2:0:0: +364,59,174622,5,8,0:0:0:0: +410,341,174784,1,8,0:0:0:0: +205,35,174946,1,8,0:0:0:0: +123,310,175108,1,8,0:0:0:0: +411,183,175270,5,4,3:2:0:0: +80,49,175432,1,4,3:2:0:0: +67,42,176081,5,0,3:2:0:0: +231,249,176243,1,8,0:0:0:0: +433,101,176405,6,0,B|406:83|394:47|394:47|373:160,1,174.869991728257,4|0,3:2|0:0,0:0:0:0: +427,207,176730,1,0,3:2:0:0: +281,95,176892,1,8,0:0:0:0: +471,27,177054,1,2,0:0:0:0: +273,219,177216,5,10,0:0:0:0: +273,219,177297,1,8,0:0:0:0: +273,219,177378,2,0,P|246:213|220:194,1,58.2899972427522,8|8,0:0|0:0,0:0:0:0: +377,361,177541,6,0,P|407:355|441:327,1,57.4200017523194,6|0,3:2|0:0,0:0:0:0: +465,274,177865,5,6,3:1:0:0: +100,334,182892,5,0,3:2:0:0: +72,268,183054,2,0,P|133:256|210:340,1,167.04,2|0,3:2|3:2,0:0:0:0: +328,140,183703,2,0,L|210:168,1,111.36,10|0,0:0|0:0,0:0:0:0: +349,304,184189,1,8,0:1:0:0: +349,304,184270,1,8,0:1:0:0: +349,304,184351,5,0,3:2:0:0: +349,304,184838,5,0,3:2:0:0: +219,165,185000,2,0,L|233:100,1,55.68,8|0,0:0|0:0,0:0:0:0: +353,62,185324,1,2,0:0:0:0: +121,275,185568,5,8,0:1:0:0: +129,262,185649,2,0,L|210:247,1,73.0799977697755,4|8,3:1|0:1,0:0:0:0: +272,234,185892,1,8,0:1:0:0: +295,231,185973,1,2,3:2:0:0: +219,165,186135,1,8,0:0:0:0: +349,304,186297,2,0,L|333:383,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +468,214,186622,1,8,0:0:0:0: +398,238,186784,1,8,0:1:0:0: +432,55,186946,6,0,L|449:141,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +464,196,187189,1,8,0:1:0:0: +468,214,187270,1,0,3:2:0:0: +338,93,187432,1,8,0:0:0:0: +219,165,187595,5,8,3:1:0:0: +187,181,187676,1,8,0:1:0:0: +157,164,187757,1,8,0:1:0:0: +152,127,187838,1,8,0:1:0:0: +177,105,187919,1,10,3:1:0:0: +317,233,188081,1,8,0:1:0:0: +317,233,188162,1,8,0:1:0:0: +317,233,188243,6,0,L|293:356,1,109.619996654663,2|8,3:2|0:1,0:0:0:0: +353,384,188568,1,2,3:2:0:0: +216,275,188730,1,8,0:0:0:0: +385,171,188892,6,0,P|393:206|401:270,1,73.0799977697755,2|8,3:2|0:1,0:0:0:0: +194,34,189216,2,0,P|183:68|176:117,1,73.0799977697755,8|8,0:0|0:1,0:0:0:0: +122,156,189460,1,8,0:1:0:0: +122,156,189541,5,10,0:0:0:0: +301,45,189703,1,8,0:0:0:0: +301,45,189784,1,8,0:0:0:0: +301,45,189865,1,8,0:0:0:0: +385,171,190027,5,8,0:0:0:0: +194,34,190189,1,8,3:2:0:0: +194,34,190270,1,8,0:0:0:0: +194,34,190351,6,0,L|204:98,1,36.5399988848877,8|0,0:0|0:0,0:0:0:0: +42,248,190514,1,8,3:2:0:0: +42,248,190595,1,8,0:0:0:0: +42,248,190676,2,0,L|52:184,1,36.5399988848877,8|8,0:0|0:0,0:0:0:0: +16,145,190838,6,0,P|87:131|151:217,1,167.04,2|0,3:2|3:2,0:0:0:0: +296,345,191487,5,8,3:2:0:0: +261,370,191568,1,8,0:0:0:0: +221,354,191649,2,0,P|211:329|215:295,1,58.2899972427522,8|0,0:0|0:0,0:0:0:0: +378,138,191811,5,4,3:2:0:0: +343,113,191892,1,8,0:0:0:0: +303,129,191973,2,0,P|293:154|297:188,1,58.2899972427522,8|0,0:0|0:0,0:0:0:0: +352,270,192135,6,0,B|243:247|274:348|133:321,1,198.359997578613,6|8,3:1|0:0,0:0:0:0: +295,184,192622,1,2,0:0:0:0: +129,67,192784,2,0,P|174:101|225:92,1,99.1799987893067,2|0,3:2|3:2,0:0:0:0: +87,223,193108,1,10,0:0:0:0: +231,80,193270,1,2,0:0:0:0: +231,80,193351,1,2,0:0:0:0: +231,80,193432,6,0,P|282:32|373:53,1,148.76999818396,2|0,3:2|0:0,0:0:0:0: +414,88,193757,2,0,L|392:192,1,99.1799987893067,8|0,0:0|0:0,0:0:0:0: +273,328,194081,1,0,3:2:0:0: +486,204,194243,1,0,3:2:0:0: +307,111,194405,1,8,0:0:0:0: +431,324,194568,1,2,0:0:0:0: +434,307,194649,1,2,0:0:0:0: +437,293,194730,6,0,B|395:250|342:273|342:273|316:332|262:327,1,198.359997578613,2|8,3:2|0:0,0:0:0:0: +119,181,195216,1,2,0:0:0:0: +30,306,195378,2,0,P|76:292|138:282,1,99.1799987893067,2|0,3:2|3:2,0:0:0:0: +231,143,195703,1,10,0:0:0:0: +102,33,195865,1,2,0:0:0:0: +102,33,195946,1,2,0:0:0:0: +102,33,196027,6,0,P|148:48|209:58,1,99.1799987893067,2|0,3:2|3:2,0:0:0:0: +89,175,196351,1,8,0:0:0:0: +260,250,196514,1,2,0:0:0:0: +97,370,196676,5,4,3:2:0:0: +247,163,196838,1,8,0:0:0:0: +247,163,196919,1,8,0:0:0:0: +247,163,197000,2,0,L|312:179,1,49.5899993946533,0|0,3:2|0:0,0:0:0:0: +473,339,197162,1,8,0:0:0:0: +441,363,197243,1,8,0:0:0:0: +403,349,197324,6,0,L|423:197,1,148.76999818396,6|0,3:2|0:0,0:0:0:0: +472,140,197649,2,0,P|504:182|483:243,1,99.1799987893067,8|0,0:0|0:0,0:0:0:0: +344,101,197973,1,2,3:2:0:0: +470,38,198135,1,0,3:2:0:0: +347,231,198297,1,8,0:0:0:0: +208,9,198460,1,2,0:0:0:0: +208,9,198541,1,2,0:0:0:0: +208,9,198622,6,0,P|251:27|317:42,1,99.1799987893067,4|2,3:2|0:0,0:0:0:0: +189,186,198946,2,0,B|102:182|137:235|14:229,1,148.76999818396,10|0,0:0|0:0,0:0:0:0: +0,175,199270,1,2,3:2:0:0: +192,352,199432,1,0,3:2:0:0: +0,175,199595,2,0,P|13:126|25:60,1,99.1799987893067,10|0,0:0|0:0,0:0:0:0: +244,262,199919,6,0,P|232:189|227:87,1,148.76999818396,2|0,3:2|0:0,0:0:0:0: +174,61,200243,1,8,0:0:0:0: +49,228,200405,1,2,3:2:0:0: +259,127,200568,5,8,0:0:0:0: +86,2,200730,1,0,3:2:0:0: +86,2,200811,1,0,3:2:0:0: +86,2,200892,5,8,0:0:0:0: +244,262,201054,1,0,3:2:0:0: +23,124,201216,6,0,P|68:109|130:99,1,99.1799987893067,4|0,3:2|3:2,0:0:0:0: +293,0,201541,1,8,0:0:0:0: +447,147,201703,1,8,0:1:0:0: +428,166,201784,1,8,0:1:0:0: +397,165,201865,1,8,0:0:0:0: +483,51,202027,1,0,3:2:0:0: +380,149,202189,6,0,P|321:128|257:179,1,148.76999818396,2|0,3:2|0:0,0:0:0:0: +186,215,202514,6,0,B|288:193|266:293|408:270,1,198.359997578613,4|8,3:1|0:0,0:0:0:0: +208,96,203000,1,0,3:2:0:0: +261,329,203162,1,10,0:0:0:0: +380,149,203324,1,0,3:2:0:0: +186,215,203487,1,10,0:0:0:0: +378,364,203649,1,0,3:2:0:0: +279,239,203811,6,0,B|200:252|200:252|165:290|165:290|80:301,1,198.359997578613,4|8,3:2|0:0,0:0:0:0: +281,153,204297,1,2,0:0:0:0: +110,46,204460,1,8,0:0:0:0: +186,215,204622,1,0,3:2:0:0: +315,72,204784,2,0,P|387:66|439:132,1,148.76999818396,10|2,0:0|0:0,0:0:0:0: +512,176,205108,6,0,B|434:143|434:143|349:175|390:251,1,198.359997578613,6|8,3:2|0:0,0:0:0:0: +512,96,205595,1,2,0:0:0:0: +302,224,205757,5,8,0:0:0:0: +496,334,205919,1,0,3:2:0:0: +363,152,206081,1,10,0:0:0:0: +226,365,206243,1,0,3:2:0:0: +377,226,206405,6,0,B|315:233|279:184|296:182|266:125|194:150,1,198.359997578613,4|8,3:2|0:0,0:0:0:0: +317,310,206892,1,0,3:2:0:0: +282,65,207054,1,4,3:2:0:0: +108,232,207216,1,2,3:2:0:0: +0,163,207378,5,10,0:0:0:0: +21,119,207460,1,8,0:0:0:0: +65,101,207541,1,10,0:0:0:0: +116,115,207622,1,8,0:0:0:0: +131,132,207703,6,0,P|119:172|110:229,1,73.0799977697755,2|0,3:2|0:0,0:0:0:0: +209,284,208027,1,2,0:0:0:0: +87,342,208189,5,0,3:2:0:0: +228,206,208351,2,0,P|261:222|306:236,1,73.0799977697755,8|2,0:0|0:0,0:0:0:0: +317,310,208676,1,2,0:0:0:0: +468,196,208838,5,2,0:0:0:0: +457,192,208919,1,2,0:0:0:0: +444,186,209000,2,0,P|446:223|457:280,1,73.0799977697755,0|0,3:2|0:0,0:0:0:0: +302,84,209324,2,0,P|291:120|286:170,1,73.0799977697755,0|0,3:2|0:0,0:0:0:0: +445,43,209649,5,8,0:0:0:0: +228,206,209892,5,2,0:0:0:0: +447,384,210135,5,2,0:0:0:0: +313,229,210297,6,0,B|231:226|256:272|164:265,1,148.76999818396,4|0,3:2|0:0,0:0:0:0: +138,334,210622,1,0,3:2:0:0: +6,131,210784,6,0,B|116:94|116:94|148:155|89:189,1,198.359997578613,10|8,0:0|0:1,0:0:0:0: +52,220,211189,1,8,0:1:0:0: +47,207,211270,6,0,B|103:220|103:220|118:253|118:253|187:269,1,148.76999818396,4|0,3:2|0:0,0:0:0:0: +226,322,211595,1,0,3:2:0:0: +381,194,211757,6,0,B|361:84|361:84|269:98,1,198.359997578613,8|8,0:0|0:1,0:0:0:0: +237,161,212162,1,8,0:1:0:0: +237,161,212243,6,0,L|305:181,1,49.5899993946533,2|0,3:2|0:0,0:0:0:0: +422,292,212405,2,0,L|351:313,1,49.5899993946533,0|0,3:2|0:0,0:0:0:0: +451,128,212568,6,0,L|463:203,1,49.5899993946533,0|0,3:2|0:0,0:0:0:0: +263,324,212730,2,0,L|275:260,1,49.5899993946533,0|0,3:2|0:0,0:0:0:0: +455,384,212892,5,4,3:2:0:0: +422,292,213054,1,0,3:2:0:0: +321,384,213216,5,0,3:2:0:0: +378,188,213378,1,0,3:2:0:0: +194,379,213541,5,8,0:0:0:0: +130,108,213703,1,8,0:0:0:0: +341,272,213865,1,8,0:0:0:0: +63,384,214027,1,8,0:0:0:0: +243,34,214189,5,4,3:2:0:0: +321,384,214351,1,4,3:2:0:0: +15,145,215162,6,0,B|117:120|117:120|92:149|95:208,1,174.869991728257,6|0,3:2|0:0,0:0:0:0: +172,178,215487,6,0,B|276:158|248:252|371:237,1,198.359997578613,4|8,3:1|0:0,0:0:0:0: +233,321,215973,1,2,0:0:0:0: +406,180,216135,2,0,P|437:224|420:278,1,99.1799987893067,0|0,3:2|3:2,0:0:0:0: +261,148,216460,2,0,B|336:128|295:94|391:69,1,148.76999818396,8|2,0:0|0:0,0:0:0:0: +461,67,216784,6,0,B|388:52|388:52|359:12|359:12|278:-2,1,198.359997578613,2|10,3:2|0:0,0:0:0:0: +406,180,217270,1,2,0:0:0:0: +277,7,217432,6,0,L|173:31,1,99.1799987893067,0|0,3:2|3:2,0:0:0:0: +23,168,217757,2,0,B|55:139|97:145|97:145|115:179|162:192,1,148.76999818396,8|2,0:0|0:0,0:0:0:0: +231,234,218081,6,0,B|163:209|163:209|64:266|123:343,1,198.359997578613,4|8,3:2|0:0,0:0:0:0: +278,165,218568,1,0,0:0:0:0: +348,279,218730,2,0,P|389:308|448:301,1,99.1799987893067,2|0,3:2|3:2,0:0:0:0: +299,68,219054,2,0,L|274:183,1,99.1799987893067,8|0,0:0|0:0,0:0:0:0: +376,7,219378,5,2,3:2:0:0: +204,94,219541,1,2,0:0:0:0: +361,202,219703,1,10,0:0:0:0: +185,326,219865,2,0,L|209:210,1,99.1799987893067,2|4,0:0|3:2,0:0:0:0: +389,313,220189,1,8,0:1:0:0: +388,356,220271,1,8,0:1:0:0: +354,384,220352,1,8,3:1:0:0: +313,384,220433,1,8,0:1:0:0: +285,353,220514,1,8,0:1:0:0: +205,228,220676,6,0,P|150:165|31:201,1,198.359997578613,6|8,3:2|0:0,0:0:0:0: +202,91,221162,1,2,0:0:0:0: +44,0,221324,2,0,P|83:30|139:27,1,99.1799987893067,0|0,3:2|3:2,0:0:0:0: +45,183,221649,6,0,P|35:236|21:298,1,99.1799987893067,8|2,0:0|0:0,0:0:0:0: +78,333,221892,1,2,0:0:0:0: +78,333,221973,2,0,B|159:326|131:378|246:372,1,148.76999818396,2|0,3:2|0:0,0:0:0:0: +285,341,222297,5,8,0:0:0:0: +184,211,222460,1,2,0:0:0:0: +310,164,222622,5,0,3:2:0:0: +163,325,222784,1,0,3:2:0:0: +310,164,222946,2,0,L|324:54,1,99.1799987893067,8|8,0:0|0:1,0:0:0:0: +268,13,223189,1,8,0:1:0:0: +268,13,223270,5,4,3:2:0:0: +406,106,223432,1,2,0:0:0:0: +184,211,223595,1,8,0:0:0:0: +292,88,223757,1,2,0:0:0:0: +361,307,223919,5,10,0:0:0:0: +214,119,224081,1,0,3:2:0:0: +214,119,224162,1,0,3:2:0:0: +214,119,224243,1,0,3:2:0:0: +129,290,224405,5,4,3:2:0:0: +129,290,224730,5,2,0:0:0:0: +86,152,224892,1,8,3:0:0:0: +155,177,225054,1,4,3:0:0:0: +31,270,225216,1,0,3:0:0:0: +186,353,225378,1,8,3:0:0:0: +112,377,225541,1,4,3:0:0:0: +410,158,225865,5,6,3:1:0:0: +350,203,226027,2,0,L|360:270,1,55.68,2|0,0:0|0:0,0:0:0:0: +193,83,226351,5,2,0:0:0:0: +246,136,226514,2,0,L|233:202,1,55.68,2|0,0:0|0:0,0:0:0:0: +394,25,226838,6,0,L|327:43,1,55.68,2|0,0:0|0:0,0:0:0:0: +269,59,227162,1,2,0:0:0:0: +109,153,227324,6,0,L|172:171,1,55.68,2|0,0:0|0:0,0:0:0:0: +235,190,227649,1,2,0:0:0:0: +107,348,227811,6,0,L|99:293,2,55.68,2|0|0,0:0|0:0|0:0,0:0:0:0: +176,384,228297,1,2,0:0:0:0: +321,307,228460,6,0,L|333:237,1,55.68,2|0,0:0|0:0,0:0:0:0: +271,203,228784,1,2,0:0:0:0: +437,70,228946,6,0,L|446:134,1,55.68,2|0,0:0|0:0,0:0:0:0: +394,174,229270,1,2,0:0:0:0: +291,42,229432,6,0,L|231:22,1,55.68,2|0,0:0|0:0,0:0:0:0: +288,119,229757,1,2,0:0:0:0: +150,209,229919,6,0,L|220:192,2,55.68,2|0|0,0:0|0:0|0:0,0:0:0:0: +88,167,230405,2,0,L|100:107,1,55.68,2|0,0:0|0:0,0:0:0:0: +274,285,230730,6,0,L|280:329,2,37.12,2|2|2,0:0|0:0|0:0,0:0:0:0: +314,217,231054,6,0,P|362:210|409:260,1,111.36,2|0,0:0|0:0,0:0:0:0: +512,130,231541,1,2,0:0:0:0: +452,88,231703,1,2,0:0:0:0: +406,143,231865,1,2,0:0:0:0: +280,54,232027,6,0,L|292:-12,1,55.68,2|0,0:0|0:0,0:0:0:0: +348,89,232351,1,2,0:0:0:0: +186,175,232514,6,0,L|177:103,1,55.68,2|0,0:0|0:0,0:0:0:0: +120,222,232838,1,2,0:0:0:0: +224,364,233000,5,2,0:0:0:0: +157,342,233162,1,2,0:0:0:0: +293,248,233324,5,2,0:0:0:0: +384,221,233487,1,2,0:0:0:0: diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/801165-expected-conversion.json b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/801165-expected-conversion.json new file mode 100644 index 000000000000..0309bb90fdc6 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/801165-expected-conversion.json @@ -0,0 +1 @@ +{"Mappings":[{"StartTime":6337.0,"Objects":[{"StartTime":6337.0,"EndTime":6337.0,"X":378.0,"Y":163.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":6559.0,"EndTime":6559.0,"X":467.160767,"Y":186.920609,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":6745.0,"EndTime":6745.0,"X":378.0,"Y":163.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7115.0,"Objects":[{"StartTime":7115.0,"EndTime":7115.0,"X":320.0,"Y":312.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7190.0,"EndTime":7190.0,"X":332.12677,"Y":360.507141,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7560.0,"Objects":[{"StartTime":7560.0,"EndTime":7560.0,"X":164.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7587.0,"EndTime":7587.0,"X":139.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7671.0,"Objects":[{"StartTime":7671.0,"EndTime":7671.0,"X":139.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7698.0,"EndTime":7698.0,"X":114.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7782.0,"Objects":[{"StartTime":7782.0,"EndTime":7782.0,"X":114.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7809.0,"EndTime":7809.0,"X":89.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":7893.0,"Objects":[{"StartTime":7893.0,"EndTime":7893.0,"X":89.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":7920.0,"EndTime":7920.0,"X":64.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":8004.0,"Objects":[{"StartTime":8004.0,"EndTime":8004.0,"X":32.0,"Y":184.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":8079.0,"EndTime":8079.0,"X":42.8465233,"Y":135.190643,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":8226.0,"Objects":[{"StartTime":8226.0,"EndTime":8226.0,"X":144.0,"Y":128.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":8301.0,"EndTime":8301.0,"X":154.846527,"Y":79.19064,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":8449.0,"Objects":[{"StartTime":8449.0,"EndTime":8449.0,"X":266.0,"Y":68.0}]},{"StartTime":8560.0,"Objects":[{"StartTime":8560.0,"EndTime":8560.0,"X":306.0,"Y":40.0}]},{"StartTime":8671.0,"Objects":[{"StartTime":8671.0,"EndTime":8671.0,"X":356.0,"Y":39.0}]},{"StartTime":8782.0,"Objects":[{"StartTime":8782.0,"EndTime":8782.0,"X":397.0,"Y":66.0}]},{"StartTime":8893.0,"Objects":[{"StartTime":8893.0,"EndTime":8893.0,"X":415.0,"Y":111.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":8968.0,"EndTime":8968.0,"X":439.476746,"Y":154.599182,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":9115.0,"Objects":[{"StartTime":9115.0,"EndTime":9115.0,"X":429.0,"Y":263.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":9190.0,"EndTime":9190.0,"X":454.28598,"Y":306.1349,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":9337.0,"Objects":[{"StartTime":9337.0,"EndTime":9337.0,"X":328.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":9364.0,"EndTime":9364.0,"X":315.596527,"Y":250.293915,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":9449.0,"Objects":[{"StartTime":9449.0,"EndTime":9449.0,"X":315.0,"Y":250.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":9476.0,"EndTime":9476.0,"X":302.3705,"Y":228.424637,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":9560.0,"Objects":[{"StartTime":9560.0,"EndTime":9560.0,"X":303.0,"Y":228.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":9587.0,"EndTime":9587.0,"X":290.506927,"Y":206.345367,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":9671.0,"Objects":[{"StartTime":9671.0,"EndTime":9671.0,"X":290.0,"Y":207.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":9698.0,"EndTime":9698.0,"X":277.506927,"Y":185.345367,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":9782.0,"Objects":[{"StartTime":9782.0,"EndTime":9782.0,"X":248.0,"Y":152.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":9857.0,"EndTime":9857.0,"X":248.0,"Y":102.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":10004.0,"Objects":[{"StartTime":10004.0,"EndTime":10004.0,"X":154.0,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":10079.0,"EndTime":10079.0,"X":154.0,"Y":86.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":10226.0,"Objects":[{"StartTime":10226.0,"EndTime":10226.0,"X":64.0,"Y":168.0}]},{"StartTime":10337.0,"Objects":[{"StartTime":10337.0,"EndTime":10337.0,"X":19.0,"Y":187.0}]},{"StartTime":10449.0,"Objects":[{"StartTime":10449.0,"EndTime":10449.0,"X":0.0,"Y":232.0}]},{"StartTime":10560.0,"Objects":[{"StartTime":10560.0,"EndTime":10560.0,"X":14.0,"Y":278.0}]},{"StartTime":10671.0,"Objects":[{"StartTime":10671.0,"EndTime":10671.0,"X":56.0,"Y":303.0}]},{"StartTime":10782.0,"Objects":[{"StartTime":10782.0,"EndTime":10782.0,"X":101.0,"Y":282.0}]},{"StartTime":10893.0,"Objects":[{"StartTime":10893.0,"EndTime":10893.0,"X":151.0,"Y":282.0}]},{"StartTime":11004.0,"Objects":[{"StartTime":11004.0,"EndTime":11004.0,"X":196.0,"Y":302.0}]},{"StartTime":11115.0,"Objects":[{"StartTime":11115.0,"EndTime":11115.0,"X":240.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11142.0,"EndTime":11142.0,"X":265.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":11226.0,"Objects":[{"StartTime":11226.0,"EndTime":11226.0,"X":265.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11253.0,"EndTime":11253.0,"X":290.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":11337.0,"Objects":[{"StartTime":11337.0,"EndTime":11337.0,"X":290.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11364.0,"EndTime":11364.0,"X":315.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":11449.0,"Objects":[{"StartTime":11449.0,"EndTime":11449.0,"X":315.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11476.0,"EndTime":11476.0,"X":340.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":11560.0,"Objects":[{"StartTime":11560.0,"EndTime":11560.0,"X":384.0,"Y":340.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11635.0,"EndTime":11635.0,"X":411.735016,"Y":298.3975,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":11782.0,"Objects":[{"StartTime":11782.0,"EndTime":11782.0,"X":376.0,"Y":196.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":11857.0,"EndTime":11857.0,"X":348.264984,"Y":237.602509,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":12004.0,"Objects":[{"StartTime":12004.0,"EndTime":12004.0,"X":416.0,"Y":308.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":12079.0,"EndTime":12079.0,"X":427.118,"Y":259.25177,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":12226.0,"Objects":[{"StartTime":12226.0,"EndTime":12226.0,"X":359.0,"Y":175.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":12301.0,"EndTime":12301.0,"X":346.87323,"Y":223.507126,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":12449.0,"Objects":[{"StartTime":12449.0,"EndTime":12449.0,"X":440.0,"Y":260.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":12524.0,"EndTime":12524.0,"X":434.009,"Y":210.360229,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":12671.0,"Objects":[{"StartTime":12671.0,"EndTime":12671.0,"X":340.0,"Y":154.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":12746.0,"EndTime":12746.0,"X":345.234253,"Y":203.725281,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":12893.0,"Objects":[{"StartTime":12893.0,"EndTime":12893.0,"X":432.0,"Y":124.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":12920.0,"EndTime":12920.0,"X":422.71524,"Y":100.788086,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":13004.0,"Objects":[{"StartTime":13004.0,"EndTime":13004.0,"X":422.0,"Y":100.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13031.0,"EndTime":13031.0,"X":412.53418,"Y":76.86133,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":13115.0,"Objects":[{"StartTime":13115.0,"EndTime":13115.0,"X":412.0,"Y":76.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13142.0,"EndTime":13142.0,"X":402.152,"Y":53.0213737,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":13226.0,"Objects":[{"StartTime":13226.0,"EndTime":13226.0,"X":403.0,"Y":53.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13253.0,"EndTime":13253.0,"X":393.567566,"Y":29.8476925,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":13337.0,"Objects":[{"StartTime":13337.0,"EndTime":13337.0,"X":352.0,"Y":4.0}]},{"StartTime":13449.0,"Objects":[{"StartTime":13449.0,"EndTime":13449.0,"X":352.0,"Y":4.0}]},{"StartTime":13560.0,"Objects":[{"StartTime":13560.0,"EndTime":13560.0,"X":352.0,"Y":4.0}]},{"StartTime":13671.0,"Objects":[{"StartTime":13671.0,"EndTime":13671.0,"X":352.0,"Y":4.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13746.0,"EndTime":13746.0,"X":337.441925,"Y":51.8336945,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":13893.0,"Objects":[{"StartTime":13893.0,"EndTime":13893.0,"X":257.0,"Y":89.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":13968.0,"EndTime":13968.0,"X":242.441925,"Y":41.1663055,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":17004.0,"Objects":[{"StartTime":17004.0,"EndTime":17004.0,"X":66.0,"Y":72.0}]},{"StartTime":17226.0,"Objects":[{"StartTime":17226.0,"EndTime":17226.0,"X":128.0,"Y":199.0}]},{"StartTime":17449.0,"Objects":[{"StartTime":17449.0,"EndTime":17449.0,"X":207.0,"Y":82.0}]},{"StartTime":17560.0,"Objects":[{"StartTime":17560.0,"EndTime":17560.0,"X":283.0,"Y":160.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":17635.0,"EndTime":17635.0,"X":332.767517,"Y":155.183792,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":17782.0,"Objects":[{"StartTime":17782.0,"EndTime":17782.0,"X":487.0,"Y":113.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":17893.0,"EndTime":17893.0,"X":437.356171,"Y":107.04274,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":18004.0,"EndTime":18004.0,"X":487.0,"Y":113.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":18079.0,"EndTime":18079.0,"X":437.356171,"Y":107.04274,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":18226.0,"Objects":[{"StartTime":18226.0,"EndTime":18226.0,"X":403.0,"Y":76.0}]},{"StartTime":18337.0,"Objects":[{"StartTime":18337.0,"EndTime":18337.0,"X":365.0,"Y":107.0}]},{"StartTime":18449.0,"Objects":[{"StartTime":18449.0,"EndTime":18449.0,"X":356.0,"Y":156.0}]},{"StartTime":18560.0,"Objects":[{"StartTime":18560.0,"EndTime":18560.0,"X":379.0,"Y":199.0}]},{"StartTime":18671.0,"Objects":[{"StartTime":18671.0,"EndTime":18671.0,"X":423.0,"Y":222.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":18746.0,"EndTime":18746.0,"X":472.643829,"Y":227.95726,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":18893.0,"Objects":[{"StartTime":18893.0,"EndTime":18893.0,"X":332.0,"Y":345.0}]},{"StartTime":19004.0,"Objects":[{"StartTime":19004.0,"EndTime":19004.0,"X":332.0,"Y":345.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":19079.0,"EndTime":19079.0,"X":324.509735,"Y":295.56424,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":19226.0,"Objects":[{"StartTime":19226.0,"EndTime":19226.0,"X":211.0,"Y":233.0}]},{"StartTime":19337.0,"Objects":[{"StartTime":19337.0,"EndTime":19337.0,"X":211.0,"Y":233.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":19412.0,"EndTime":19412.0,"X":203.509735,"Y":282.43576,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":19560.0,"Objects":[{"StartTime":19560.0,"EndTime":19560.0,"X":91.0,"Y":298.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":19635.0,"EndTime":19635.0,"X":41.3058128,"Y":292.478424,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":19782.0,"Objects":[{"StartTime":19782.0,"EndTime":19782.0,"X":166.0,"Y":184.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":19857.0,"EndTime":19857.0,"X":174.567062,"Y":233.26059,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":20004.0,"Objects":[{"StartTime":20004.0,"EndTime":20004.0,"X":220.0,"Y":356.0}]},{"StartTime":20115.0,"Objects":[{"StartTime":20115.0,"EndTime":20115.0,"X":263.0,"Y":331.0}]},{"StartTime":20226.0,"Objects":[{"StartTime":20226.0,"EndTime":20226.0,"X":312.0,"Y":322.0}]},{"StartTime":20337.0,"Objects":[{"StartTime":20337.0,"EndTime":20337.0,"X":361.0,"Y":329.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":20412.0,"EndTime":20412.0,"X":393.297119,"Y":367.1693,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":20560.0,"Objects":[{"StartTime":20560.0,"EndTime":20560.0,"X":457.0,"Y":201.0}]},{"StartTime":20671.0,"Objects":[{"StartTime":20671.0,"EndTime":20671.0,"X":457.0,"Y":201.0}]},{"StartTime":20782.0,"Objects":[{"StartTime":20782.0,"EndTime":20782.0,"X":457.0,"Y":201.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":20893.0,"EndTime":20893.0,"X":459.171875,"Y":151.0472,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":20968.0,"EndTime":20968.0,"X":457.0,"Y":201.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":21115.0,"Objects":[{"StartTime":21115.0,"EndTime":21115.0,"X":344.0,"Y":244.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":21190.0,"EndTime":21190.0,"X":335.893219,"Y":184.550186,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":21337.0,"Objects":[{"StartTime":21337.0,"EndTime":21337.0,"X":214.0,"Y":70.0}]},{"StartTime":21449.0,"Objects":[{"StartTime":21449.0,"EndTime":21449.0,"X":263.0,"Y":76.0}]},{"StartTime":21560.0,"Objects":[{"StartTime":21560.0,"EndTime":21560.0,"X":313.0,"Y":82.0}]},{"StartTime":21671.0,"Objects":[{"StartTime":21671.0,"EndTime":21671.0,"X":362.0,"Y":88.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":21857.0,"EndTime":21857.0,"X":265.04364,"Y":112.483932,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":22004.0,"Objects":[{"StartTime":22004.0,"EndTime":22004.0,"X":164.0,"Y":64.0}]},{"StartTime":22115.0,"Objects":[{"StartTime":22115.0,"EndTime":22115.0,"X":164.0,"Y":64.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":22190.0,"EndTime":22190.0,"X":114.386108,"Y":57.7982635,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":22337.0,"Objects":[{"StartTime":22337.0,"EndTime":22337.0,"X":21.0,"Y":221.0}]},{"StartTime":22449.0,"Objects":[{"StartTime":22449.0,"EndTime":22449.0,"X":69.0,"Y":229.0}]},{"StartTime":22560.0,"Objects":[{"StartTime":22560.0,"EndTime":22560.0,"X":114.0,"Y":208.0}]},{"StartTime":22671.0,"Objects":[{"StartTime":22671.0,"EndTime":22671.0,"X":139.0,"Y":166.0}]},{"StartTime":22782.0,"Objects":[{"StartTime":22782.0,"EndTime":22782.0,"X":145.0,"Y":226.0}]},{"StartTime":22893.0,"Objects":[{"StartTime":22893.0,"EndTime":22893.0,"X":150.0,"Y":286.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":23079.0,"EndTime":23079.0,"X":258.789642,"Y":261.437683,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":23226.0,"Objects":[{"StartTime":23226.0,"EndTime":23226.0,"X":384.0,"Y":243.0}]},{"StartTime":23337.0,"Objects":[{"StartTime":23337.0,"EndTime":23337.0,"X":384.0,"Y":243.0}]},{"StartTime":23449.0,"Objects":[{"StartTime":23449.0,"EndTime":23449.0,"X":384.0,"Y":243.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":23560.0,"EndTime":23560.0,"X":388.9752,"Y":193.248138,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":23635.0,"EndTime":23635.0,"X":384.0,"Y":243.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":23782.0,"Objects":[{"StartTime":23782.0,"EndTime":23782.0,"X":334.0,"Y":331.0}]},{"StartTime":23893.0,"Objects":[{"StartTime":23893.0,"EndTime":23893.0,"X":285.0,"Y":319.0}]},{"StartTime":24004.0,"Objects":[{"StartTime":24004.0,"EndTime":24004.0,"X":236.0,"Y":325.0}]},{"StartTime":24115.0,"Objects":[{"StartTime":24115.0,"EndTime":24115.0,"X":191.0,"Y":346.0}]},{"StartTime":24226.0,"Objects":[{"StartTime":24226.0,"EndTime":24226.0,"X":155.0,"Y":381.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":24412.0,"EndTime":24412.0,"X":55.3892822,"Y":372.1849,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":24560.0,"Objects":[{"StartTime":24560.0,"EndTime":24560.0,"X":148.0,"Y":254.0}]},{"StartTime":24671.0,"Objects":[{"StartTime":24671.0,"EndTime":24671.0,"X":148.0,"Y":254.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":24857.0,"EndTime":24857.0,"X":247.529541,"Y":244.311279,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":25115.0,"Objects":[{"StartTime":25115.0,"EndTime":25115.0,"X":90.0,"Y":134.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":25190.0,"EndTime":25190.0,"X":96.39857,"Y":183.5889,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":25337.0,"Objects":[{"StartTime":25337.0,"EndTime":25337.0,"X":30.0,"Y":218.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":25523.0,"EndTime":25523.0,"X":55.3264046,"Y":72.97656,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":25671.0,"Objects":[{"StartTime":25671.0,"EndTime":25671.0,"X":179.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":25857.0,"EndTime":25857.0,"X":275.556946,"Y":103.126785,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":26004.0,"Objects":[{"StartTime":26004.0,"EndTime":26004.0,"X":349.0,"Y":47.0}]},{"StartTime":26115.0,"Objects":[{"StartTime":26115.0,"EndTime":26115.0,"X":349.0,"Y":47.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":26301.0,"EndTime":26301.0,"X":328.957245,"Y":140.003632,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":26449.0,"Objects":[{"StartTime":26449.0,"EndTime":26449.0,"X":411.0,"Y":158.0}]},{"StartTime":26560.0,"Objects":[{"StartTime":26560.0,"EndTime":26560.0,"X":411.0,"Y":158.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":26746.0,"EndTime":26746.0,"X":431.042755,"Y":64.99636,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":26893.0,"Objects":[{"StartTime":26893.0,"EndTime":26893.0,"X":351.0,"Y":96.0}]},{"StartTime":27115.0,"Objects":[{"StartTime":27115.0,"EndTime":27115.0,"X":384.0,"Y":255.0}]},{"StartTime":27226.0,"Objects":[{"StartTime":27226.0,"EndTime":27226.0,"X":384.0,"Y":255.0}]},{"StartTime":27337.0,"Objects":[{"StartTime":27337.0,"EndTime":27337.0,"X":384.0,"Y":255.0}]},{"StartTime":27449.0,"Objects":[{"StartTime":27449.0,"EndTime":27449.0,"X":384.0,"Y":255.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":27560.0,"EndTime":27560.0,"X":433.843262,"Y":251.044189,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":27635.0,"EndTime":27635.0,"X":384.0,"Y":255.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":27782.0,"Objects":[{"StartTime":27782.0,"EndTime":27782.0,"X":228.0,"Y":173.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":27857.0,"EndTime":27857.0,"X":186.890381,"Y":201.4605,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":28004.0,"Objects":[{"StartTime":28004.0,"EndTime":28004.0,"X":279.0,"Y":342.0}]},{"StartTime":28115.0,"Objects":[{"StartTime":28115.0,"EndTime":28115.0,"X":279.0,"Y":342.0}]},{"StartTime":28226.0,"Objects":[{"StartTime":28226.0,"EndTime":28226.0,"X":279.0,"Y":342.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":28301.0,"EndTime":28301.0,"X":279.81955,"Y":292.0067,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":28449.0,"Objects":[{"StartTime":28449.0,"EndTime":28449.0,"X":357.0,"Y":136.0}]},{"StartTime":28560.0,"Objects":[{"StartTime":28560.0,"EndTime":28560.0,"X":307.0,"Y":139.0}]},{"StartTime":28671.0,"Objects":[{"StartTime":28671.0,"EndTime":28671.0,"X":257.0,"Y":142.0}]},{"StartTime":28782.0,"Objects":[{"StartTime":28782.0,"EndTime":28782.0,"X":207.0,"Y":145.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":28893.0,"EndTime":28893.0,"X":157.19812,"Y":149.4466,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":28968.0,"EndTime":28968.0,"X":207.0,"Y":145.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":29115.0,"Objects":[{"StartTime":29115.0,"EndTime":29115.0,"X":257.0,"Y":142.0}]},{"StartTime":29226.0,"Objects":[{"StartTime":29226.0,"EndTime":29226.0,"X":307.0,"Y":139.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":29412.0,"EndTime":29412.0,"X":404.7521,"Y":160.083786,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":29560.0,"Objects":[{"StartTime":29560.0,"EndTime":29560.0,"X":445.0,"Y":188.0}]},{"StartTime":29671.0,"Objects":[{"StartTime":29671.0,"EndTime":29671.0,"X":468.0,"Y":231.0}]},{"StartTime":29782.0,"Objects":[{"StartTime":29782.0,"EndTime":29782.0,"X":464.0,"Y":280.0}]},{"StartTime":29893.0,"Objects":[{"StartTime":29893.0,"EndTime":29893.0,"X":435.0,"Y":320.0}]},{"StartTime":30004.0,"Objects":[{"StartTime":30004.0,"EndTime":30004.0,"X":389.0,"Y":339.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":30079.0,"EndTime":30079.0,"X":339.970978,"Y":329.194183,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":30226.0,"Objects":[{"StartTime":30226.0,"EndTime":30226.0,"X":177.0,"Y":222.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":30253.0,"EndTime":30253.0,"X":158.74321,"Y":204.921066,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":30337.0,"Objects":[{"StartTime":30337.0,"EndTime":30337.0,"X":145.0,"Y":248.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":30364.0,"EndTime":30364.0,"X":121.750984,"Y":238.808533,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":30449.0,"Objects":[{"StartTime":30449.0,"EndTime":30449.0,"X":130.0,"Y":287.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":30476.0,"EndTime":30476.0,"X":105.070015,"Y":288.869751,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":30560.0,"Objects":[{"StartTime":30560.0,"EndTime":30560.0,"X":134.0,"Y":328.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":30746.0,"EndTime":30746.0,"X":228.590485,"Y":343.960876,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":30893.0,"Objects":[{"StartTime":30893.0,"EndTime":30893.0,"X":294.0,"Y":251.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":30968.0,"EndTime":30968.0,"X":290.61792,"Y":201.114517,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":31115.0,"Objects":[{"StartTime":31115.0,"EndTime":31115.0,"X":226.0,"Y":74.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":31190.0,"EndTime":31190.0,"X":222.617935,"Y":123.885483,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":31337.0,"Objects":[{"StartTime":31337.0,"EndTime":31337.0,"X":359.0,"Y":150.0}]},{"StartTime":31782.0,"Objects":[{"StartTime":31782.0,"EndTime":31782.0,"X":359.0,"Y":150.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":31857.0,"EndTime":31857.0,"X":439.999084,"Y":129.902328,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":32004.0,"Objects":[{"StartTime":32004.0,"EndTime":32004.0,"X":340.0,"Y":240.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":32190.0,"EndTime":32190.0,"X":295.539337,"Y":65.57739,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":32449.0,"Objects":[{"StartTime":32449.0,"EndTime":32449.0,"X":176.0,"Y":132.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":32635.0,"EndTime":32635.0,"X":21.1092339,"Y":143.401688,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":32893.0,"Objects":[{"StartTime":32893.0,"EndTime":32893.0,"X":139.0,"Y":383.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":33079.0,"EndTime":33079.0,"X":103.72628,"Y":232.345047,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":33337.0,"Objects":[{"StartTime":33337.0,"EndTime":33337.0,"X":205.0,"Y":291.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":33523.0,"EndTime":33523.0,"X":383.4717,"Y":314.406128,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":33782.0,"Objects":[{"StartTime":33782.0,"EndTime":33782.0,"X":506.0,"Y":223.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":33968.0,"EndTime":33968.0,"X":327.5283,"Y":199.593872,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":34226.0,"Objects":[{"StartTime":34226.0,"EndTime":34226.0,"X":182.0,"Y":205.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":34412.0,"EndTime":34412.0,"X":326.651245,"Y":199.355911,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":34671.0,"Objects":[{"StartTime":34671.0,"EndTime":34671.0,"X":76.0,"Y":191.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":34857.0,"EndTime":34857.0,"X":70.35591,"Y":46.3487358,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":35115.0,"Objects":[{"StartTime":35115.0,"EndTime":35115.0,"X":182.0,"Y":205.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":35190.0,"EndTime":35190.0,"X":158.54007,"Y":291.8886,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":35337.0,"Objects":[{"StartTime":35337.0,"EndTime":35337.0,"X":257.0,"Y":361.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":35412.0,"EndTime":35412.0,"X":280.45993,"Y":274.1114,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":35560.0,"Objects":[{"StartTime":35560.0,"EndTime":35560.0,"X":334.0,"Y":174.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":35746.0,"EndTime":35746.0,"X":399.559753,"Y":305.239624,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":36004.0,"Objects":[{"StartTime":36004.0,"EndTime":36004.0,"X":447.0,"Y":191.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":36190.0,"EndTime":36190.0,"X":505.1221,"Y":20.6420746,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":36449.0,"Objects":[{"StartTime":36449.0,"EndTime":36449.0,"X":334.0,"Y":174.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":36635.0,"EndTime":36635.0,"X":189.812637,"Y":134.164047,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":36893.0,"Objects":[{"StartTime":36893.0,"EndTime":36893.0,"X":133.0,"Y":25.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":37079.0,"EndTime":37079.0,"X":155.499313,"Y":203.5883,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":37337.0,"Objects":[{"StartTime":37337.0,"EndTime":37337.0,"X":22.0,"Y":281.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":37523.0,"EndTime":37523.0,"X":173.001678,"Y":318.749268,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":37782.0,"Objects":[{"StartTime":37782.0,"EndTime":37782.0,"X":306.0,"Y":240.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":37968.0,"EndTime":37968.0,"X":154.714111,"Y":203.211349,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":38226.0,"Objects":[{"StartTime":38226.0,"EndTime":38226.0,"X":0.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":38412.0,"EndTime":38412.0,"X":178.1909,"Y":66.54416,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":38671.0,"Objects":[{"StartTime":38671.0,"EndTime":38671.0,"X":363.0,"Y":43.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":38746.0,"EndTime":38746.0,"X":393.257751,"Y":124.443619,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":38893.0,"Objects":[{"StartTime":38893.0,"EndTime":38893.0,"X":306.0,"Y":240.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":38968.0,"EndTime":38968.0,"X":275.742249,"Y":158.556381,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":39115.0,"Objects":[{"StartTime":39115.0,"EndTime":39115.0,"X":421.0,"Y":293.0}]},{"StartTime":39226.0,"Objects":[{"StartTime":39226.0,"EndTime":39226.0,"X":368.0,"Y":278.0}]},{"StartTime":39337.0,"Objects":[{"StartTime":39337.0,"EndTime":39337.0,"X":313.0,"Y":273.0}]},{"StartTime":39449.0,"Objects":[{"StartTime":39449.0,"EndTime":39449.0,"X":259.0,"Y":284.0}]},{"StartTime":39560.0,"Objects":[{"StartTime":39560.0,"EndTime":39560.0,"X":214.0,"Y":316.0}]},{"StartTime":39671.0,"Objects":[{"StartTime":39671.0,"EndTime":39671.0,"X":166.0,"Y":343.0}]},{"StartTime":39782.0,"Objects":[{"StartTime":39782.0,"EndTime":39782.0,"X":112.0,"Y":332.0}]},{"StartTime":39893.0,"Objects":[{"StartTime":39893.0,"EndTime":39893.0,"X":76.0,"Y":289.0}]},{"StartTime":40004.0,"Objects":[{"StartTime":40004.0,"EndTime":40004.0,"X":68.0,"Y":234.0}]},{"StartTime":40115.0,"Objects":[{"StartTime":40115.0,"EndTime":40115.0,"X":94.0,"Y":185.0}]},{"StartTime":40226.0,"Objects":[{"StartTime":40226.0,"EndTime":40226.0,"X":136.0,"Y":149.0}]},{"StartTime":40337.0,"Objects":[{"StartTime":40337.0,"EndTime":40337.0,"X":190.0,"Y":147.0}]},{"StartTime":40449.0,"Objects":[{"StartTime":40449.0,"EndTime":40449.0,"X":241.0,"Y":167.0}]},{"StartTime":40560.0,"Objects":[{"StartTime":40560.0,"EndTime":40560.0,"X":261.0,"Y":217.0}]},{"StartTime":40671.0,"Objects":[{"StartTime":40671.0,"EndTime":40671.0,"X":240.0,"Y":267.0}]},{"StartTime":40782.0,"Objects":[{"StartTime":40782.0,"EndTime":40782.0,"X":188.0,"Y":285.0}]},{"StartTime":40893.0,"Objects":[{"StartTime":40893.0,"EndTime":40893.0,"X":135.0,"Y":268.0}]},{"StartTime":41004.0,"Objects":[{"StartTime":41004.0,"EndTime":41004.0,"X":114.0,"Y":216.0}]},{"StartTime":41115.0,"Objects":[{"StartTime":41115.0,"EndTime":41115.0,"X":137.0,"Y":166.0}]},{"StartTime":41226.0,"Objects":[{"StartTime":41226.0,"EndTime":41226.0,"X":190.0,"Y":147.0}]},{"StartTime":41337.0,"Objects":[{"StartTime":41337.0,"EndTime":41337.0,"X":241.0,"Y":167.0}]},{"StartTime":41449.0,"Objects":[{"StartTime":41449.0,"EndTime":41449.0,"X":295.0,"Y":170.0}]},{"StartTime":41560.0,"Objects":[{"StartTime":41560.0,"EndTime":41560.0,"X":348.0,"Y":157.0}]},{"StartTime":41671.0,"Objects":[{"StartTime":41671.0,"EndTime":41671.0,"X":390.0,"Y":121.0}]},{"StartTime":41782.0,"Objects":[{"StartTime":41782.0,"EndTime":41782.0,"X":394.0,"Y":66.0}]},{"StartTime":41893.0,"Objects":[{"StartTime":41893.0,"EndTime":41893.0,"X":364.0,"Y":18.0}]},{"StartTime":42004.0,"Objects":[{"StartTime":42004.0,"EndTime":42004.0,"X":316.0,"Y":0.0}]},{"StartTime":42115.0,"Objects":[{"StartTime":42115.0,"EndTime":42115.0,"X":262.0,"Y":11.0}]},{"StartTime":42226.0,"Objects":[{"StartTime":42226.0,"EndTime":42226.0,"X":214.0,"Y":26.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":42301.0,"EndTime":42301.0,"X":125.114845,"Y":40.12194,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":42449.0,"Objects":[{"StartTime":42449.0,"EndTime":42449.0,"X":2.0,"Y":149.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":42524.0,"EndTime":42524.0,"X":90.8851547,"Y":163.121948,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":42671.0,"Objects":[{"StartTime":42671.0,"EndTime":42671.0,"X":336.0,"Y":200.0}]},{"StartTime":42782.0,"Objects":[{"StartTime":42782.0,"EndTime":42782.0,"X":288.0,"Y":172.0}]},{"StartTime":42893.0,"Objects":[{"StartTime":42893.0,"EndTime":42893.0,"X":232.0,"Y":173.0}]},{"StartTime":43004.0,"Objects":[{"StartTime":43004.0,"EndTime":43004.0,"X":189.0,"Y":207.0}]},{"StartTime":43115.0,"Objects":[{"StartTime":43115.0,"EndTime":43115.0,"X":160.0,"Y":255.0}]},{"StartTime":43226.0,"Objects":[{"StartTime":43226.0,"EndTime":43226.0,"X":168.0,"Y":308.0}]},{"StartTime":43337.0,"Objects":[{"StartTime":43337.0,"EndTime":43337.0,"X":196.0,"Y":355.0}]},{"StartTime":43449.0,"Objects":[{"StartTime":43449.0,"EndTime":43449.0,"X":249.0,"Y":366.0}]},{"StartTime":43560.0,"Objects":[{"StartTime":43560.0,"EndTime":43560.0,"X":295.0,"Y":337.0}]},{"StartTime":43671.0,"Objects":[{"StartTime":43671.0,"EndTime":43671.0,"X":303.0,"Y":283.0}]},{"StartTime":43782.0,"Objects":[{"StartTime":43782.0,"EndTime":43782.0,"X":277.0,"Y":233.0}]},{"StartTime":43893.0,"Objects":[{"StartTime":43893.0,"EndTime":43893.0,"X":224.0,"Y":216.0}]},{"StartTime":44004.0,"Objects":[{"StartTime":44004.0,"EndTime":44004.0,"X":172.0,"Y":228.0}]},{"StartTime":44115.0,"Objects":[{"StartTime":44115.0,"EndTime":44115.0,"X":124.0,"Y":248.0}]},{"StartTime":44226.0,"Objects":[{"StartTime":44226.0,"EndTime":44226.0,"X":72.0,"Y":232.0}]},{"StartTime":44337.0,"Objects":[{"StartTime":44337.0,"EndTime":44337.0,"X":32.0,"Y":196.0}]},{"StartTime":44449.0,"Objects":[{"StartTime":44449.0,"EndTime":44449.0,"X":28.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":44476.0,"EndTime":44476.0,"X":4.282917,"Y":136.0943,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":44560.0,"Objects":[{"StartTime":44560.0,"EndTime":44560.0,"X":62.0,"Y":96.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":44587.0,"EndTime":44587.0,"X":47.14022,"Y":75.8956,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":44671.0,"Objects":[{"StartTime":44671.0,"EndTime":44671.0,"X":119.0,"Y":77.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":44698.0,"EndTime":44698.0,"X":119.0,"Y":52.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":44782.0,"Objects":[{"StartTime":44782.0,"EndTime":44782.0,"X":175.0,"Y":96.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":44809.0,"EndTime":44809.0,"X":189.276611,"Y":75.47737,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":44893.0,"Objects":[{"StartTime":44893.0,"EndTime":44893.0,"X":210.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":44920.0,"EndTime":44920.0,"X":233.624649,"Y":135.822235,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":45004.0,"Objects":[{"StartTime":45004.0,"EndTime":45004.0,"X":228.0,"Y":200.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":45031.0,"EndTime":45031.0,"X":204.282913,"Y":207.9057,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":45115.0,"Objects":[{"StartTime":45115.0,"EndTime":45115.0,"X":262.0,"Y":248.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":45142.0,"EndTime":45142.0,"X":247.140228,"Y":268.1044,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":45226.0,"Objects":[{"StartTime":45226.0,"EndTime":45226.0,"X":319.0,"Y":267.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":45253.0,"EndTime":45253.0,"X":319.0,"Y":292.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":45337.0,"Objects":[{"StartTime":45337.0,"EndTime":45337.0,"X":375.0,"Y":248.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":45364.0,"EndTime":45364.0,"X":389.2766,"Y":268.522644,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":45449.0,"Objects":[{"StartTime":45449.0,"EndTime":45449.0,"X":410.0,"Y":200.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":45476.0,"EndTime":45476.0,"X":433.624664,"Y":208.177765,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":45560.0,"Objects":[{"StartTime":45560.0,"EndTime":45560.0,"X":410.0,"Y":141.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":45587.0,"EndTime":45587.0,"X":433.624664,"Y":132.822235,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":45671.0,"Objects":[{"StartTime":45671.0,"EndTime":45671.0,"X":375.0,"Y":93.0}]},{"StartTime":46004.0,"Objects":[{"StartTime":46004.0,"EndTime":46004.0,"X":375.0,"Y":93.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":46190.0,"EndTime":46190.0,"X":325.360229,"Y":87.0089951,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":46337.0,"Objects":[{"StartTime":46337.0,"EndTime":46337.0,"X":317.0,"Y":167.0}]},{"StartTime":46449.0,"Objects":[{"StartTime":46449.0,"EndTime":46449.0,"X":317.0,"Y":167.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":46635.0,"EndTime":46635.0,"X":167.871689,"Y":183.147659,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":46782.0,"Objects":[{"StartTime":46782.0,"EndTime":46782.0,"X":53.0,"Y":101.0}]},{"StartTime":46893.0,"Objects":[{"StartTime":46893.0,"EndTime":46893.0,"X":108.0,"Y":98.0}]},{"StartTime":47004.0,"Objects":[{"StartTime":47004.0,"EndTime":47004.0,"X":152.0,"Y":130.0}]},{"StartTime":47115.0,"Objects":[{"StartTime":47115.0,"EndTime":47115.0,"X":167.0,"Y":183.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":47190.0,"EndTime":47190.0,"X":161.719025,"Y":257.813843,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":47337.0,"Objects":[{"StartTime":47337.0,"EndTime":47337.0,"X":49.0,"Y":308.0}]},{"StartTime":47449.0,"Objects":[{"StartTime":47449.0,"EndTime":47449.0,"X":49.0,"Y":308.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":47524.0,"EndTime":47524.0,"X":45.39839,"Y":233.086517,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":47671.0,"Objects":[{"StartTime":47671.0,"EndTime":47671.0,"X":205.0,"Y":140.0}]},{"StartTime":47782.0,"Objects":[{"StartTime":47782.0,"EndTime":47782.0,"X":205.0,"Y":140.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":47857.0,"EndTime":47857.0,"X":208.601608,"Y":214.913483,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":48004.0,"Objects":[{"StartTime":48004.0,"EndTime":48004.0,"X":310.0,"Y":353.0}]},{"StartTime":48115.0,"Objects":[{"StartTime":48115.0,"EndTime":48115.0,"X":346.0,"Y":347.0}]},{"StartTime":48226.0,"Objects":[{"StartTime":48226.0,"EndTime":48226.0,"X":383.0,"Y":341.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":48412.0,"EndTime":48412.0,"X":429.122864,"Y":223.2328,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":48560.0,"Objects":[{"StartTime":48560.0,"EndTime":48560.0,"X":317.0,"Y":167.0}]},{"StartTime":48671.0,"Objects":[{"StartTime":48671.0,"EndTime":48671.0,"X":307.0,"Y":112.0}]},{"StartTime":48782.0,"Objects":[{"StartTime":48782.0,"EndTime":48782.0,"X":333.0,"Y":64.0}]},{"StartTime":48893.0,"Objects":[{"StartTime":48893.0,"EndTime":48893.0,"X":384.0,"Y":43.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":48968.0,"EndTime":48968.0,"X":458.365082,"Y":52.7382851,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":49115.0,"Objects":[{"StartTime":49115.0,"EndTime":49115.0,"X":506.0,"Y":161.0}]},{"StartTime":49226.0,"Objects":[{"StartTime":49226.0,"EndTime":49226.0,"X":506.0,"Y":161.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":49301.0,"EndTime":49301.0,"X":431.634918,"Y":151.261719,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":49449.0,"Objects":[{"StartTime":49449.0,"EndTime":49449.0,"X":268.0,"Y":121.0}]},{"StartTime":49560.0,"Objects":[{"StartTime":49560.0,"EndTime":49560.0,"X":268.0,"Y":121.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":49635.0,"EndTime":49635.0,"X":342.365082,"Y":111.261719,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":49782.0,"Objects":[{"StartTime":49782.0,"EndTime":49782.0,"X":263.0,"Y":263.0}]},{"StartTime":49893.0,"Objects":[{"StartTime":49893.0,"EndTime":49893.0,"X":228.0,"Y":247.0}]},{"StartTime":50004.0,"Objects":[{"StartTime":50004.0,"EndTime":50004.0,"X":193.0,"Y":232.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":50190.0,"EndTime":50190.0,"X":64.17105,"Y":285.840179,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":50337.0,"Objects":[{"StartTime":50337.0,"EndTime":50337.0,"X":121.0,"Y":164.0}]},{"StartTime":50449.0,"Objects":[{"StartTime":50449.0,"EndTime":50449.0,"X":120.0,"Y":109.0}]},{"StartTime":50560.0,"Objects":[{"StartTime":50560.0,"EndTime":50560.0,"X":91.0,"Y":62.0}]},{"StartTime":50671.0,"Objects":[{"StartTime":50671.0,"EndTime":50671.0,"X":42.0,"Y":37.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":50746.0,"EndTime":50746.0,"X":116.636856,"Y":29.628458,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":50893.0,"Objects":[{"StartTime":50893.0,"EndTime":50893.0,"X":242.0,"Y":84.0}]},{"StartTime":51004.0,"Objects":[{"StartTime":51004.0,"EndTime":51004.0,"X":242.0,"Y":84.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":51079.0,"EndTime":51079.0,"X":310.8907,"Y":113.649155,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":51226.0,"Objects":[{"StartTime":51226.0,"EndTime":51226.0,"X":341.0,"Y":245.0}]},{"StartTime":51337.0,"Objects":[{"StartTime":51337.0,"EndTime":51337.0,"X":341.0,"Y":245.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":51412.0,"EndTime":51412.0,"X":351.364716,"Y":319.280365,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":51560.0,"Objects":[{"StartTime":51560.0,"EndTime":51560.0,"X":163.0,"Y":192.0}]},{"StartTime":51671.0,"Objects":[{"StartTime":51671.0,"EndTime":51671.0,"X":198.0,"Y":181.0}]},{"StartTime":51782.0,"Objects":[{"StartTime":51782.0,"EndTime":51782.0,"X":233.0,"Y":170.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":51968.0,"EndTime":51968.0,"X":365.301575,"Y":207.308578,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":52115.0,"Objects":[{"StartTime":52115.0,"EndTime":52115.0,"X":458.0,"Y":297.0}]},{"StartTime":52226.0,"Objects":[{"StartTime":52226.0,"EndTime":52226.0,"X":460.0,"Y":240.0}]},{"StartTime":52337.0,"Objects":[{"StartTime":52337.0,"EndTime":52337.0,"X":463.0,"Y":184.0}]},{"StartTime":52449.0,"Objects":[{"StartTime":52449.0,"EndTime":52449.0,"X":466.0,"Y":128.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":52635.0,"EndTime":52635.0,"X":420.259766,"Y":144.350586,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":52782.0,"Objects":[{"StartTime":52782.0,"EndTime":52782.0,"X":272.0,"Y":189.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":52857.0,"EndTime":52857.0,"X":197.534241,"Y":180.0641,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":53004.0,"Objects":[{"StartTime":53004.0,"EndTime":53004.0,"X":338.0,"Y":25.0}]},{"StartTime":53115.0,"Objects":[{"StartTime":53115.0,"EndTime":53115.0,"X":338.0,"Y":25.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":53190.0,"EndTime":53190.0,"X":344.228424,"Y":99.74094,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":53337.0,"Objects":[{"StartTime":53337.0,"EndTime":53337.0,"X":179.0,"Y":225.0}]},{"StartTime":53449.0,"Objects":[{"StartTime":53449.0,"EndTime":53449.0,"X":175.0,"Y":187.0}]},{"StartTime":53560.0,"Objects":[{"StartTime":53560.0,"EndTime":53560.0,"X":172.0,"Y":149.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":53746.0,"EndTime":53746.0,"X":321.020142,"Y":166.117188,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":53893.0,"Objects":[{"StartTime":53893.0,"EndTime":53893.0,"X":296.0,"Y":293.0}]},{"StartTime":54004.0,"Objects":[{"StartTime":54004.0,"EndTime":54004.0,"X":259.0,"Y":334.0}]},{"StartTime":54115.0,"Objects":[{"StartTime":54115.0,"EndTime":54115.0,"X":209.0,"Y":357.0}]},{"StartTime":54226.0,"Objects":[{"StartTime":54226.0,"EndTime":54226.0,"X":154.0,"Y":358.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":54301.0,"EndTime":54301.0,"X":79.53424,"Y":349.064117,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":54449.0,"Objects":[{"StartTime":54449.0,"EndTime":54449.0,"X":179.0,"Y":225.0}]},{"StartTime":54560.0,"Objects":[{"StartTime":54560.0,"EndTime":54560.0,"X":179.0,"Y":225.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":54635.0,"EndTime":54635.0,"X":253.565521,"Y":233.061142,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":54782.0,"Objects":[{"StartTime":54782.0,"EndTime":54782.0,"X":91.0,"Y":140.0}]},{"StartTime":54893.0,"Objects":[{"StartTime":54893.0,"EndTime":54893.0,"X":91.0,"Y":140.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":54968.0,"EndTime":54968.0,"X":16.4230576,"Y":147.95488,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":55115.0,"Objects":[{"StartTime":55115.0,"EndTime":55115.0,"X":191.0,"Y":43.0}]},{"StartTime":55226.0,"Objects":[{"StartTime":55226.0,"EndTime":55226.0,"X":195.0,"Y":80.0}]},{"StartTime":55337.0,"Objects":[{"StartTime":55337.0,"EndTime":55337.0,"X":199.0,"Y":117.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":55523.0,"EndTime":55523.0,"X":163.504578,"Y":187.945709,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":55671.0,"Objects":[{"StartTime":55671.0,"EndTime":55671.0,"X":289.0,"Y":165.0}]},{"StartTime":55782.0,"Objects":[{"StartTime":55782.0,"EndTime":55782.0,"X":344.0,"Y":159.0}]},{"StartTime":55893.0,"Objects":[{"StartTime":55893.0,"EndTime":55893.0,"X":399.0,"Y":154.0}]},{"StartTime":56004.0,"Objects":[{"StartTime":56004.0,"EndTime":56004.0,"X":454.0,"Y":149.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":56079.0,"EndTime":56079.0,"X":460.084534,"Y":223.752777,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":56226.0,"Objects":[{"StartTime":56226.0,"EndTime":56226.0,"X":359.0,"Y":281.0}]},{"StartTime":56337.0,"Objects":[{"StartTime":56337.0,"EndTime":56337.0,"X":359.0,"Y":281.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":56412.0,"EndTime":56412.0,"X":364.280975,"Y":355.813843,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":56560.0,"Objects":[{"StartTime":56560.0,"EndTime":56560.0,"X":262.0,"Y":132.0}]},{"StartTime":56671.0,"Objects":[{"StartTime":56671.0,"EndTime":56671.0,"X":262.0,"Y":132.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":56746.0,"EndTime":56746.0,"X":267.280975,"Y":206.813843,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":56893.0,"Objects":[{"StartTime":56893.0,"EndTime":56893.0,"X":148.0,"Y":358.0}]},{"StartTime":57004.0,"Objects":[{"StartTime":57004.0,"EndTime":57004.0,"X":110.0,"Y":355.0}]},{"StartTime":57115.0,"Objects":[{"StartTime":57115.0,"EndTime":57115.0,"X":79.0,"Y":333.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":57301.0,"EndTime":57301.0,"X":200.969864,"Y":306.864716,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":57449.0,"Objects":[{"StartTime":57449.0,"EndTime":57449.0,"X":329.0,"Y":327.0}]},{"StartTime":57560.0,"Objects":[{"StartTime":57560.0,"EndTime":57560.0,"X":359.0,"Y":281.0}]},{"StartTime":57671.0,"Objects":[{"StartTime":57671.0,"EndTime":57671.0,"X":364.0,"Y":226.0}]},{"StartTime":57782.0,"Objects":[{"StartTime":57782.0,"EndTime":57782.0,"X":343.0,"Y":175.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":57857.0,"EndTime":57857.0,"X":268.32077,"Y":181.929,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":58004.0,"Objects":[{"StartTime":58004.0,"EndTime":58004.0,"X":342.0,"Y":20.0}]},{"StartTime":58115.0,"Objects":[{"StartTime":58115.0,"EndTime":58115.0,"X":342.0,"Y":20.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58190.0,"EndTime":58190.0,"X":384.690735,"Y":81.66441,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":58337.0,"Objects":[{"StartTime":58337.0,"EndTime":58337.0,"X":210.0,"Y":97.0}]},{"StartTime":58449.0,"Objects":[{"StartTime":58449.0,"EndTime":58449.0,"X":210.0,"Y":97.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58524.0,"EndTime":58524.0,"X":240.894089,"Y":28.6585312,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":58671.0,"Objects":[{"StartTime":58671.0,"EndTime":58671.0,"X":343.0,"Y":175.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58726.0,"EndTime":58726.0,"X":305.7774,"Y":176.959091,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58782.0,"EndTime":58782.0,"X":342.7744,"Y":175.011871,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58801.0,"EndTime":58801.0,"X":305.551819,"Y":176.970963,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":58893.0,"Objects":[{"StartTime":58893.0,"EndTime":58893.0,"X":209.0,"Y":209.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":58948.0,"EndTime":58948.0,"X":244.437592,"Y":220.55574,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59004.0,"EndTime":59004.0,"X":209.214767,"Y":209.070038,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59023.0,"EndTime":59023.0,"X":244.652374,"Y":220.625778,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59115.0,"Objects":[{"StartTime":59115.0,"EndTime":59115.0,"X":316.0,"Y":267.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59170.0,"EndTime":59170.0,"X":290.0395,"Y":293.747162,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59226.0,"EndTime":59226.0,"X":315.842651,"Y":267.1621,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59245.0,"EndTime":59245.0,"X":289.882172,"Y":293.909271,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59337.0,"Objects":[{"StartTime":59337.0,"EndTime":59337.0,"X":211.0,"Y":329.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59392.0,"EndTime":59392.0,"X":199.992722,"Y":293.388245,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59448.0,"EndTime":59448.0,"X":210.933289,"Y":328.78418,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59467.0,"EndTime":59467.0,"X":199.92601,"Y":293.1724,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59560.0,"Objects":[{"StartTime":59560.0,"EndTime":59560.0,"X":103.0,"Y":287.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59587.0,"EndTime":59587.0,"X":67.42438,"Y":275.141449,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59671.0,"Objects":[{"StartTime":59671.0,"EndTime":59671.0,"X":108.0,"Y":240.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59698.0,"EndTime":59698.0,"X":72.42438,"Y":228.141464,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59782.0,"Objects":[{"StartTime":59782.0,"EndTime":59782.0,"X":113.0,"Y":193.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":59809.0,"EndTime":59809.0,"X":77.42438,"Y":181.141464,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":59893.0,"Objects":[{"StartTime":59893.0,"EndTime":59893.0,"X":120.0,"Y":146.0}]},{"StartTime":60226.0,"Objects":[{"StartTime":60226.0,"EndTime":60226.0,"X":120.0,"Y":146.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":60412.0,"EndTime":60412.0,"X":130.32193,"Y":46.53414,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":60893.0,"Objects":[{"StartTime":60893.0,"EndTime":60893.0,"X":374.0,"Y":174.0}]},{"StartTime":61115.0,"Objects":[{"StartTime":61115.0,"EndTime":61115.0,"X":326.0,"Y":323.0}]},{"StartTime":61337.0,"Objects":[{"StartTime":61337.0,"EndTime":61337.0,"X":462.0,"Y":267.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":61412.0,"EndTime":61412.0,"X":512.0,"Y":267.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":61560.0,"Objects":[{"StartTime":61560.0,"EndTime":61560.0,"X":344.0,"Y":182.0}]},{"StartTime":61671.0,"Objects":[{"StartTime":61671.0,"EndTime":61671.0,"X":344.0,"Y":182.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":61746.0,"EndTime":61746.0,"X":302.261353,"Y":209.529739,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":61893.0,"Objects":[{"StartTime":61893.0,"EndTime":61893.0,"X":174.0,"Y":241.0}]},{"StartTime":62004.0,"Objects":[{"StartTime":62004.0,"EndTime":62004.0,"X":174.0,"Y":241.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":62079.0,"EndTime":62079.0,"X":215.738632,"Y":268.529724,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":62226.0,"Objects":[{"StartTime":62226.0,"EndTime":62226.0,"X":66.0,"Y":161.0}]},{"StartTime":62449.0,"Objects":[{"StartTime":62449.0,"EndTime":62449.0,"X":104.0,"Y":326.0}]},{"StartTime":62671.0,"Objects":[{"StartTime":62671.0,"EndTime":62671.0,"X":269.0,"Y":288.0}]},{"StartTime":62893.0,"Objects":[{"StartTime":62893.0,"EndTime":62893.0,"X":231.0,"Y":122.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":62968.0,"EndTime":62968.0,"X":220.153473,"Y":73.19064,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":63115.0,"Objects":[{"StartTime":63115.0,"EndTime":63115.0,"X":296.0,"Y":0.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":63190.0,"EndTime":63190.0,"X":306.846527,"Y":48.8093529,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":63337.0,"Objects":[{"StartTime":63337.0,"EndTime":63337.0,"X":373.0,"Y":120.0}]},{"StartTime":63449.0,"Objects":[{"StartTime":63449.0,"EndTime":63449.0,"X":373.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":63635.0,"EndTime":63635.0,"X":465.0729,"Y":136.1981,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":63782.0,"Objects":[{"StartTime":63782.0,"EndTime":63782.0,"X":400.0,"Y":216.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":63968.0,"EndTime":63968.0,"X":301.005066,"Y":230.142136,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":64226.0,"Objects":[{"StartTime":64226.0,"EndTime":64226.0,"X":48.0,"Y":160.0}]},{"StartTime":64449.0,"Objects":[{"StartTime":64449.0,"EndTime":64449.0,"X":216.0,"Y":200.0}]},{"StartTime":64671.0,"Objects":[{"StartTime":64671.0,"EndTime":64671.0,"X":104.0,"Y":288.0}]},{"StartTime":64893.0,"Objects":[{"StartTime":64893.0,"EndTime":64893.0,"X":216.0,"Y":200.0}]},{"StartTime":65115.0,"Objects":[{"StartTime":65115.0,"EndTime":65115.0,"X":160.0,"Y":64.0}]},{"StartTime":65226.0,"Objects":[{"StartTime":65226.0,"EndTime":65226.0,"X":160.0,"Y":64.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":65301.0,"EndTime":65301.0,"X":160.0,"Y":114.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":65449.0,"Objects":[{"StartTime":65449.0,"EndTime":65449.0,"X":264.0,"Y":104.0}]},{"StartTime":65560.0,"Objects":[{"StartTime":65560.0,"EndTime":65560.0,"X":264.0,"Y":104.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":65635.0,"EndTime":65635.0,"X":264.0,"Y":154.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":65782.0,"Objects":[{"StartTime":65782.0,"EndTime":65782.0,"X":456.0,"Y":160.0}]},{"StartTime":66004.0,"Objects":[{"StartTime":66004.0,"EndTime":66004.0,"X":288.0,"Y":192.0}]},{"StartTime":66226.0,"Objects":[{"StartTime":66226.0,"EndTime":66226.0,"X":336.0,"Y":48.0}]},{"StartTime":66449.0,"Objects":[{"StartTime":66449.0,"EndTime":66449.0,"X":408.0,"Y":296.0}]},{"StartTime":66671.0,"Objects":[{"StartTime":66671.0,"EndTime":66671.0,"X":196.0,"Y":148.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":66857.0,"EndTime":66857.0,"X":199.142975,"Y":244.112289,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":67004.0,"Objects":[{"StartTime":67004.0,"EndTime":67004.0,"X":288.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67079.0,"EndTime":67079.0,"X":335.8913,"Y":206.3674,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":67226.0,"Objects":[{"StartTime":67226.0,"EndTime":67226.0,"X":297.0,"Y":308.0}]},{"StartTime":67337.0,"Objects":[{"StartTime":67337.0,"EndTime":67337.0,"X":297.0,"Y":308.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67412.0,"EndTime":67412.0,"X":249.108688,"Y":322.3674,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":67560.0,"Objects":[{"StartTime":67560.0,"EndTime":67560.0,"X":107.0,"Y":256.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":67746.0,"EndTime":67746.0,"X":198.869858,"Y":243.930862,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":68004.0,"Objects":[{"StartTime":68004.0,"EndTime":68004.0,"X":460.0,"Y":300.0}]},{"StartTime":68226.0,"Objects":[{"StartTime":68226.0,"EndTime":68226.0,"X":407.0,"Y":107.0}]},{"StartTime":68449.0,"Objects":[{"StartTime":68449.0,"EndTime":68449.0,"X":364.0,"Y":364.0}]},{"StartTime":68671.0,"Objects":[{"StartTime":68671.0,"EndTime":68671.0,"X":345.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":68857.0,"EndTime":68857.0,"X":482.955933,"Y":34.298172,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":69115.0,"Objects":[{"StartTime":69115.0,"EndTime":69115.0,"X":167.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":69301.0,"EndTime":69301.0,"X":29.044054,"Y":34.29817,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":69560.0,"Objects":[{"StartTime":69560.0,"EndTime":69560.0,"X":407.0,"Y":107.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":69746.0,"EndTime":69746.0,"X":460.866516,"Y":235.137,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":70004.0,"Objects":[{"StartTime":70004.0,"EndTime":70004.0,"X":158.0,"Y":202.0}]},{"StartTime":70226.0,"Objects":[{"StartTime":70226.0,"EndTime":70226.0,"X":354.0,"Y":202.0}]},{"StartTime":70449.0,"Objects":[{"StartTime":70449.0,"EndTime":70449.0,"X":105.0,"Y":107.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":70635.0,"EndTime":70635.0,"X":49.8107719,"Y":234.398621,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":70893.0,"Objects":[{"StartTime":70893.0,"EndTime":70893.0,"X":364.0,"Y":281.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":71079.0,"EndTime":71079.0,"X":492.137024,"Y":334.866516,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":71337.0,"Objects":[{"StartTime":71337.0,"EndTime":71337.0,"X":424.0,"Y":64.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":71781.0,"EndTime":71781.0,"X":256.21228,"Y":97.33865,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":72225.0,"EndTime":72225.0,"X":154.641,"Y":186.241669,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":72670.0,"EndTime":72670.0,"X":297.514374,"Y":348.106232,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":72856.0,"EndTime":72856.0,"X":161.609192,"Y":331.680847,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":73337.0,"Objects":[{"StartTime":73337.0,"EndTime":73337.0,"X":161.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":73364.0,"EndTime":73364.0,"X":186.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":73449.0,"Objects":[{"StartTime":73449.0,"EndTime":73449.0,"X":186.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":73476.0,"EndTime":73476.0,"X":211.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":73560.0,"Objects":[{"StartTime":73560.0,"EndTime":73560.0,"X":211.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":73587.0,"EndTime":73587.0,"X":236.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":73671.0,"Objects":[{"StartTime":73671.0,"EndTime":73671.0,"X":236.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":73698.0,"EndTime":73698.0,"X":261.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":73782.0,"Objects":[{"StartTime":73782.0,"EndTime":73782.0,"X":297.0,"Y":339.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":73857.0,"EndTime":73857.0,"X":346.751862,"Y":343.9752,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":74004.0,"Objects":[{"StartTime":74004.0,"EndTime":74004.0,"X":321.0,"Y":181.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":74079.0,"EndTime":74079.0,"X":316.4732,"Y":230.794662,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":74226.0,"Objects":[{"StartTime":74226.0,"EndTime":74226.0,"X":184.0,"Y":116.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":74301.0,"EndTime":74301.0,"X":179.4732,"Y":66.20534,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":74449.0,"Objects":[{"StartTime":74449.0,"EndTime":74449.0,"X":283.0,"Y":82.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":74635.0,"EndTime":74635.0,"X":377.3881,"Y":94.27898,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":75115.0,"Objects":[{"StartTime":75115.0,"EndTime":75115.0,"X":150.0,"Y":188.0}]},{"StartTime":75337.0,"Objects":[{"StartTime":75337.0,"EndTime":75337.0,"X":127.0,"Y":46.0}]},{"StartTime":75560.0,"Objects":[{"StartTime":75560.0,"EndTime":75560.0,"X":157.0,"Y":218.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":75746.0,"EndTime":75746.0,"X":252.9821,"Y":201.569382,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":75893.0,"Objects":[{"StartTime":75893.0,"EndTime":75893.0,"X":322.0,"Y":283.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":76079.0,"EndTime":76079.0,"X":418.1396,"Y":269.035919,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":76226.0,"Objects":[{"StartTime":76226.0,"EndTime":76226.0,"X":439.0,"Y":170.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":76412.0,"EndTime":76412.0,"X":356.0464,"Y":190.903046,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":76671.0,"Objects":[{"StartTime":76671.0,"EndTime":76671.0,"X":219.0,"Y":92.0}]},{"StartTime":76893.0,"Objects":[{"StartTime":76893.0,"EndTime":76893.0,"X":371.0,"Y":22.0}]},{"StartTime":77115.0,"Objects":[{"StartTime":77115.0,"EndTime":77115.0,"X":356.0,"Y":191.0}]},{"StartTime":77337.0,"Objects":[{"StartTime":77337.0,"EndTime":77337.0,"X":194.0,"Y":73.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77523.0,"EndTime":77523.0,"X":94.5583954,"Y":81.89803,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":77671.0,"Objects":[{"StartTime":77671.0,"EndTime":77671.0,"X":15.0,"Y":164.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77857.0,"EndTime":77857.0,"X":97.3508148,"Y":208.780609,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":78004.0,"Objects":[{"StartTime":78004.0,"EndTime":78004.0,"X":26.0,"Y":302.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":78079.0,"EndTime":78079.0,"X":20.9243164,"Y":252.258286,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":78226.0,"Objects":[{"StartTime":78226.0,"EndTime":78226.0,"X":181.0,"Y":348.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":78412.0,"EndTime":78412.0,"X":276.906036,"Y":335.082367,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":78671.0,"Objects":[{"StartTime":78671.0,"EndTime":78671.0,"X":422.0,"Y":231.0}]},{"StartTime":78893.0,"Objects":[{"StartTime":78893.0,"EndTime":78893.0,"X":435.0,"Y":376.0}]},{"StartTime":79115.0,"Objects":[{"StartTime":79115.0,"EndTime":79115.0,"X":271.0,"Y":230.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":79301.0,"EndTime":79301.0,"X":280.305237,"Y":130.433868,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":79449.0,"Objects":[{"StartTime":79449.0,"EndTime":79449.0,"X":367.0,"Y":56.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":79635.0,"EndTime":79635.0,"X":392.598328,"Y":147.861,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":79782.0,"Objects":[{"StartTime":79782.0,"EndTime":79782.0,"X":280.0,"Y":130.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":79857.0,"EndTime":79857.0,"X":230.414871,"Y":136.4277,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":80004.0,"Objects":[{"StartTime":80004.0,"EndTime":80004.0,"X":104.0,"Y":280.0}]},{"StartTime":80226.0,"Objects":[{"StartTime":80226.0,"EndTime":80226.0,"X":243.0,"Y":207.0}]},{"StartTime":80449.0,"Objects":[{"StartTime":80449.0,"EndTime":80449.0,"X":104.0,"Y":134.0}]},{"StartTime":80671.0,"Objects":[{"StartTime":80671.0,"EndTime":80671.0,"X":243.0,"Y":61.0}]},{"StartTime":80893.0,"Objects":[{"StartTime":80893.0,"EndTime":80893.0,"X":384.0,"Y":189.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":80968.0,"EndTime":80968.0,"X":335.600159,"Y":201.548111,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":81115.0,"Objects":[{"StartTime":81115.0,"EndTime":81115.0,"X":259.0,"Y":262.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":81190.0,"EndTime":81190.0,"X":210.492874,"Y":249.873215,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":81337.0,"Objects":[{"StartTime":81337.0,"EndTime":81337.0,"X":83.0,"Y":157.0}]},{"StartTime":81449.0,"Objects":[{"StartTime":81449.0,"EndTime":81449.0,"X":60.0,"Y":186.0}]},{"StartTime":81560.0,"Objects":[{"StartTime":81560.0,"EndTime":81560.0,"X":48.0,"Y":221.0}]},{"StartTime":81671.0,"Objects":[{"StartTime":81671.0,"EndTime":81671.0,"X":48.0,"Y":259.0}]},{"StartTime":81782.0,"Objects":[{"StartTime":81782.0,"EndTime":81782.0,"X":61.0,"Y":294.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":81968.0,"EndTime":81968.0,"X":152.567673,"Y":316.582672,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":82226.0,"Objects":[{"StartTime":82226.0,"EndTime":82226.0,"X":350.0,"Y":269.0}]},{"StartTime":82449.0,"Objects":[{"StartTime":82449.0,"EndTime":82449.0,"X":226.0,"Y":187.0}]},{"StartTime":82671.0,"Objects":[{"StartTime":82671.0,"EndTime":82671.0,"X":265.0,"Y":365.0}]},{"StartTime":82893.0,"Objects":[{"StartTime":82893.0,"EndTime":82893.0,"X":444.0,"Y":207.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":83079.0,"EndTime":83079.0,"X":420.485352,"Y":85.56759,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":83337.0,"Objects":[{"StartTime":83337.0,"EndTime":83337.0,"X":211.0,"Y":48.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":83523.0,"EndTime":83523.0,"X":135.939285,"Y":144.596344,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":83782.0,"Objects":[{"StartTime":83782.0,"EndTime":83782.0,"X":270.0,"Y":337.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":83968.0,"EndTime":83968.0,"X":285.4127,"Y":210.422119,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":84226.0,"Objects":[{"StartTime":84226.0,"EndTime":84226.0,"X":427.0,"Y":124.0}]},{"StartTime":84337.0,"Objects":[{"StartTime":84337.0,"EndTime":84337.0,"X":427.0,"Y":124.0}]},{"StartTime":84449.0,"Objects":[{"StartTime":84449.0,"EndTime":84449.0,"X":427.0,"Y":124.0}]},{"StartTime":84671.0,"Objects":[{"StartTime":84671.0,"EndTime":84671.0,"X":136.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":85079.0,"EndTime":85079.0,"X":235.2148,"Y":264.073,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":85337.0,"Objects":[{"StartTime":85337.0,"EndTime":85337.0,"X":235.0,"Y":99.0}]},{"StartTime":85449.0,"Objects":[{"StartTime":85449.0,"EndTime":85449.0,"X":278.0,"Y":92.0}]},{"StartTime":85560.0,"Objects":[{"StartTime":85560.0,"EndTime":85560.0,"X":319.0,"Y":109.0}]},{"StartTime":85782.0,"Objects":[{"StartTime":85782.0,"EndTime":85782.0,"X":174.0,"Y":43.0}]},{"StartTime":85893.0,"Objects":[{"StartTime":85893.0,"EndTime":85893.0,"X":129.0,"Y":35.0}]},{"StartTime":86004.0,"Objects":[{"StartTime":86004.0,"EndTime":86004.0,"X":86.0,"Y":47.0}]},{"StartTime":86115.0,"Objects":[{"StartTime":86115.0,"EndTime":86115.0,"X":52.0,"Y":75.0}]},{"StartTime":86226.0,"Objects":[{"StartTime":86226.0,"EndTime":86226.0,"X":32.0,"Y":115.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86281.0,"EndTime":86281.0,"X":35.0821877,"Y":139.6575,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86337.0,"EndTime":86337.0,"X":32.01868,"Y":115.149437,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86356.0,"EndTime":86356.0,"X":35.10087,"Y":139.806946,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":86449.0,"Objects":[{"StartTime":86449.0,"EndTime":86449.0,"X":83.0,"Y":252.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86504.0,"EndTime":86504.0,"X":85.47261,"Y":227.273926,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86560.0,"EndTime":86560.0,"X":83.0149841,"Y":251.850143,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86579.0,"EndTime":86579.0,"X":85.4875946,"Y":227.124069,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":86671.0,"Objects":[{"StartTime":86671.0,"EndTime":86671.0,"X":174.0,"Y":189.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86782.0,"EndTime":86782.0,"X":223.892853,"Y":185.728333,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":86857.0,"EndTime":86857.0,"X":174.0,"Y":189.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":87004.0,"Objects":[{"StartTime":87004.0,"EndTime":87004.0,"X":83.0,"Y":252.0}]},{"StartTime":87115.0,"Objects":[{"StartTime":87115.0,"EndTime":87115.0,"X":83.0,"Y":252.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87190.0,"EndTime":87190.0,"X":90.5399,"Y":301.428223,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":87337.0,"Objects":[{"StartTime":87337.0,"EndTime":87337.0,"X":185.0,"Y":379.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87412.0,"EndTime":87412.0,"X":203.254379,"Y":332.451355,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":87560.0,"Objects":[{"StartTime":87560.0,"EndTime":87560.0,"X":310.0,"Y":279.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87635.0,"EndTime":87635.0,"X":359.670441,"Y":284.7312,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":87782.0,"Objects":[{"StartTime":87782.0,"EndTime":87782.0,"X":490.0,"Y":357.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87837.0,"EndTime":87837.0,"X":492.549316,"Y":381.64325,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":87857.0,"EndTime":87857.0,"X":490.0,"Y":357.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88004.0,"Objects":[{"StartTime":88004.0,"EndTime":88004.0,"X":448.701019,"Y":200.701}]},{"StartTime":88060.0,"Objects":[{"StartTime":88060.0,"EndTime":88060.0,"X":452.3505,"Y":204.35051}]},{"StartTime":88115.0,"Objects":[{"StartTime":88115.0,"EndTime":88115.0,"X":456.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":88190.0,"EndTime":88190.0,"X":465.805817,"Y":158.970963,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88337.0,"Objects":[{"StartTime":88337.0,"EndTime":88337.0,"X":352.0,"Y":40.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":88412.0,"EndTime":88412.0,"X":367.8114,"Y":87.43416,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88560.0,"Objects":[{"StartTime":88560.0,"EndTime":88560.0,"X":256.0,"Y":136.0}]},{"StartTime":88671.0,"Objects":[{"StartTime":88671.0,"EndTime":88671.0,"X":256.0,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":88746.0,"EndTime":88746.0,"X":242.263947,"Y":87.9238,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":88893.0,"Objects":[{"StartTime":88893.0,"EndTime":88893.0,"X":48.0,"Y":184.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89079.0,"EndTime":89079.0,"X":203.680511,"Y":208.498886,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":89226.0,"Objects":[{"StartTime":89226.0,"EndTime":89226.0,"X":328.0,"Y":336.0}]},{"StartTime":89337.0,"Objects":[{"StartTime":89337.0,"EndTime":89337.0,"X":424.0,"Y":328.0}]},{"StartTime":89449.0,"Objects":[{"StartTime":89449.0,"EndTime":89449.0,"X":472.0,"Y":248.0}]},{"StartTime":89560.0,"Objects":[{"StartTime":89560.0,"EndTime":89560.0,"X":488.0,"Y":160.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":89746.0,"EndTime":89746.0,"X":394.248352,"Y":41.7970772,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":89893.0,"Objects":[{"StartTime":89893.0,"EndTime":89893.0,"X":256.0,"Y":104.0}]},{"StartTime":90004.0,"Objects":[{"StartTime":90004.0,"EndTime":90004.0,"X":168.0,"Y":128.0}]},{"StartTime":90115.0,"Objects":[{"StartTime":90115.0,"EndTime":90115.0,"X":80.0,"Y":120.0}]},{"StartTime":90226.0,"Objects":[{"StartTime":90226.0,"EndTime":90226.0,"X":0.0,"Y":88.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":90301.0,"EndTime":90301.0,"X":-5.43235159,"Y":172.040863,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":90449.0,"Objects":[{"StartTime":90449.0,"EndTime":90449.0,"X":96.0,"Y":336.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":90524.0,"EndTime":90524.0,"X":101.432358,"Y":251.959152,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":90671.0,"Objects":[{"StartTime":90671.0,"EndTime":90671.0,"X":173.0,"Y":43.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":90746.0,"EndTime":90746.0,"X":167.812271,"Y":126.927834,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":90893.0,"Objects":[{"StartTime":90893.0,"EndTime":90893.0,"X":256.0,"Y":296.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":90968.0,"EndTime":90968.0,"X":261.432373,"Y":211.959152,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":91115.0,"Objects":[{"StartTime":91115.0,"EndTime":91115.0,"X":336.0,"Y":72.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":91301.0,"EndTime":91301.0,"X":505.568,"Y":59.888,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":91449.0,"Objects":[{"StartTime":91449.0,"EndTime":91449.0,"X":432.0,"Y":120.0}]},{"StartTime":91560.0,"Objects":[{"StartTime":91560.0,"EndTime":91560.0,"X":392.0,"Y":200.0}]},{"StartTime":91671.0,"Objects":[{"StartTime":91671.0,"EndTime":91671.0,"X":432.0,"Y":288.0}]},{"StartTime":91782.0,"Objects":[{"StartTime":91782.0,"EndTime":91782.0,"X":496.0,"Y":360.0}]},{"StartTime":92004.0,"Objects":[{"StartTime":92004.0,"EndTime":92004.0,"X":256.0,"Y":296.0}]},{"StartTime":92226.0,"Objects":[{"StartTime":92226.0,"EndTime":92226.0,"X":16.0,"Y":360.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":92412.0,"EndTime":92412.0,"X":57.4499359,"Y":205.3707,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":92560.0,"Objects":[{"StartTime":92560.0,"EndTime":92560.0,"X":120.0,"Y":40.0}]},{"StartTime":92671.0,"Objects":[{"StartTime":92671.0,"EndTime":92671.0,"X":168.0,"Y":120.0}]},{"StartTime":92782.0,"Objects":[{"StartTime":92782.0,"EndTime":92782.0,"X":248.0,"Y":168.0}]},{"StartTime":92893.0,"Objects":[{"StartTime":92893.0,"EndTime":92893.0,"X":328.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":93079.0,"EndTime":93079.0,"X":470.241516,"Y":176.420227,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":93226.0,"Objects":[{"StartTime":93226.0,"EndTime":93226.0,"X":472.0,"Y":360.0}]},{"StartTime":93337.0,"Objects":[{"StartTime":93337.0,"EndTime":93337.0,"X":472.0,"Y":360.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":93523.0,"EndTime":93523.0,"X":372.6112,"Y":243.111877,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":93671.0,"Objects":[{"StartTime":93671.0,"EndTime":93671.0,"X":328.0,"Y":120.0}]},{"StartTime":93782.0,"Objects":[{"StartTime":93782.0,"EndTime":93782.0,"X":328.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":93857.0,"EndTime":93857.0,"X":315.9792,"Y":35.8542938,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":94004.0,"Objects":[{"StartTime":94004.0,"EndTime":94004.0,"X":93.0,"Y":147.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":94190.0,"EndTime":94190.0,"X":247.311935,"Y":167.253586,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":94337.0,"Objects":[{"StartTime":94337.0,"EndTime":94337.0,"X":448.0,"Y":288.0}]},{"StartTime":94449.0,"Objects":[{"StartTime":94449.0,"EndTime":94449.0,"X":360.0,"Y":264.0}]},{"StartTime":94560.0,"Objects":[{"StartTime":94560.0,"EndTime":94560.0,"X":272.0,"Y":288.0}]},{"StartTime":94671.0,"Objects":[{"StartTime":94671.0,"EndTime":94671.0,"X":216.0,"Y":360.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":94857.0,"EndTime":94857.0,"X":165.263092,"Y":227.525742,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":95004.0,"Objects":[{"StartTime":95004.0,"EndTime":95004.0,"X":216.0,"Y":104.0}]},{"StartTime":95115.0,"Objects":[{"StartTime":95115.0,"EndTime":95115.0,"X":216.0,"Y":104.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":95190.0,"EndTime":95190.0,"X":231.205261,"Y":20.3710556,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":95337.0,"Objects":[{"StartTime":95337.0,"EndTime":95337.0,"X":368.0,"Y":48.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":95412.0,"EndTime":95412.0,"X":448.936432,"Y":36.0525131,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":95560.0,"Objects":[{"StartTime":95560.0,"EndTime":95560.0,"X":432.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":95635.0,"EndTime":95635.0,"X":420.052521,"Y":143.063568,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":95782.0,"Objects":[{"StartTime":95782.0,"EndTime":95782.0,"X":320.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":95857.0,"EndTime":95857.0,"X":331.947479,"Y":360.936432,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":96004.0,"Objects":[{"StartTime":96004.0,"EndTime":96004.0,"X":32.0,"Y":232.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":96190.0,"EndTime":96190.0,"X":187.590439,"Y":208.254333,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":96337.0,"Objects":[{"StartTime":96337.0,"EndTime":96337.0,"X":320.0,"Y":280.0}]},{"StartTime":96449.0,"Objects":[{"StartTime":96449.0,"EndTime":96449.0,"X":408.0,"Y":312.0}]},{"StartTime":96560.0,"Objects":[{"StartTime":96560.0,"EndTime":96560.0,"X":496.0,"Y":280.0}]},{"StartTime":96671.0,"Objects":[{"StartTime":96671.0,"EndTime":96671.0,"X":496.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":96857.0,"EndTime":96857.0,"X":456.6245,"Y":114.62294,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":97004.0,"Objects":[{"StartTime":97004.0,"EndTime":97004.0,"X":384.0,"Y":56.0}]},{"StartTime":97115.0,"Objects":[{"StartTime":97115.0,"EndTime":97115.0,"X":296.0,"Y":24.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":97190.0,"EndTime":97190.0,"X":213.0328,"Y":22.5670681,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":97337.0,"Objects":[{"StartTime":97337.0,"EndTime":97337.0,"X":104.0,"Y":160.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":97412.0,"EndTime":97412.0,"X":186.9672,"Y":161.432938,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":97560.0,"Objects":[{"StartTime":97560.0,"EndTime":97560.0,"X":456.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":97746.0,"EndTime":97746.0,"X":313.4922,"Y":206.126556,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":97893.0,"Objects":[{"StartTime":97893.0,"EndTime":97893.0,"X":264.0,"Y":288.0}]},{"StartTime":98004.0,"Objects":[{"StartTime":98004.0,"EndTime":98004.0,"X":176.0,"Y":264.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":98079.0,"EndTime":98079.0,"X":91.38288,"Y":272.058777,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":98226.0,"Objects":[{"StartTime":98226.0,"EndTime":98226.0,"X":16.0,"Y":88.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":98412.0,"EndTime":98412.0,"X":153.034653,"Y":108.397804,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":98560.0,"Objects":[{"StartTime":98560.0,"EndTime":98560.0,"X":328.0,"Y":56.0}]},{"StartTime":98671.0,"Objects":[{"StartTime":98671.0,"EndTime":98671.0,"X":416.0,"Y":32.0}]},{"StartTime":98782.0,"Objects":[{"StartTime":98782.0,"EndTime":98782.0,"X":480.0,"Y":104.0}]},{"StartTime":98893.0,"Objects":[{"StartTime":98893.0,"EndTime":98893.0,"X":456.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":98968.0,"EndTime":98968.0,"X":463.695526,"Y":276.65094,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":99115.0,"Objects":[{"StartTime":99115.0,"EndTime":99115.0,"X":328.0,"Y":360.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":99190.0,"EndTime":99190.0,"X":320.304474,"Y":275.34906,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":99337.0,"Objects":[{"StartTime":99337.0,"EndTime":99337.0,"X":208.0,"Y":112.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":99412.0,"EndTime":99412.0,"X":215.695541,"Y":196.650925,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":99560.0,"Objects":[{"StartTime":99560.0,"EndTime":99560.0,"X":64.0,"Y":320.0}]},{"StartTime":99671.0,"Objects":[{"StartTime":99671.0,"EndTime":99671.0,"X":136.0,"Y":264.0}]},{"StartTime":99782.0,"Objects":[{"StartTime":99782.0,"EndTime":99782.0,"X":232.0,"Y":256.0}]},{"StartTime":99893.0,"Objects":[{"StartTime":99893.0,"EndTime":99893.0,"X":320.0,"Y":280.0}]},{"StartTime":100004.0,"Objects":[{"StartTime":100004.0,"EndTime":100004.0,"X":384.0,"Y":344.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":100079.0,"EndTime":100079.0,"X":413.492859,"Y":264.728241,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":100226.0,"Objects":[{"StartTime":100226.0,"EndTime":100226.0,"X":360.0,"Y":72.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":100301.0,"EndTime":100301.0,"X":330.507141,"Y":151.271759,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":100449.0,"Objects":[{"StartTime":100449.0,"EndTime":100449.0,"X":240.0,"Y":312.0}]},{"StartTime":100560.0,"Objects":[{"StartTime":100560.0,"EndTime":100560.0,"X":152.0,"Y":344.0}]},{"StartTime":100671.0,"Objects":[{"StartTime":100671.0,"EndTime":100671.0,"X":64.0,"Y":320.0}]},{"StartTime":100782.0,"Objects":[{"StartTime":100782.0,"EndTime":100782.0,"X":8.0,"Y":248.0}]},{"StartTime":100893.0,"Objects":[{"StartTime":100893.0,"EndTime":100893.0,"X":16.0,"Y":152.0}]},{"StartTime":101004.0,"Objects":[{"StartTime":101004.0,"EndTime":101004.0,"X":88.0,"Y":88.0}]},{"StartTime":101115.0,"Objects":[{"StartTime":101115.0,"EndTime":101115.0,"X":184.0,"Y":80.0}]},{"StartTime":101226.0,"Objects":[{"StartTime":101226.0,"EndTime":101226.0,"X":272.0,"Y":120.0}]},{"StartTime":101337.0,"Objects":[{"StartTime":101337.0,"EndTime":101337.0,"X":356.0,"Y":176.0}]},{"StartTime":101449.0,"Objects":[{"StartTime":101449.0,"EndTime":101449.0,"X":456.0,"Y":164.0}]},{"StartTime":101560.0,"Objects":[{"StartTime":101560.0,"EndTime":101560.0,"X":392.0,"Y":88.0}]},{"StartTime":101671.0,"Objects":[{"StartTime":101671.0,"EndTime":101671.0,"X":348.0,"Y":184.0}]},{"StartTime":101782.0,"Objects":[{"StartTime":101782.0,"EndTime":101782.0,"X":448.0,"Y":172.0}]},{"StartTime":101893.0,"Objects":[{"StartTime":101893.0,"EndTime":101893.0,"X":384.0,"Y":96.0}]},{"StartTime":102004.0,"Objects":[{"StartTime":102004.0,"EndTime":102004.0,"X":340.0,"Y":192.0}]},{"StartTime":102115.0,"Objects":[{"StartTime":102115.0,"EndTime":102115.0,"X":440.0,"Y":180.0}]},{"StartTime":102226.0,"Objects":[{"StartTime":102226.0,"EndTime":102226.0,"X":376.0,"Y":104.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":102301.0,"EndTime":102301.0,"X":296.361084,"Y":106.489342,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":102449.0,"Objects":[{"StartTime":102449.0,"EndTime":102449.0,"X":344.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":102524.0,"EndTime":102524.0,"X":413.713257,"Y":230.646133,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":102671.0,"Objects":[{"StartTime":102671.0,"EndTime":102671.0,"X":169.0,"Y":239.0}]},{"StartTime":102782.0,"Objects":[{"StartTime":102782.0,"EndTime":102782.0,"X":80.0,"Y":210.0}]},{"StartTime":102893.0,"Objects":[{"StartTime":102893.0,"EndTime":102893.0,"X":80.0,"Y":117.0}]},{"StartTime":103004.0,"Objects":[{"StartTime":103004.0,"EndTime":103004.0,"X":169.0,"Y":88.0}]},{"StartTime":103115.0,"Objects":[{"StartTime":103115.0,"EndTime":103115.0,"X":224.0,"Y":164.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103301.0,"EndTime":103301.0,"X":391.5921,"Y":148.586258,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":103449.0,"Objects":[{"StartTime":103449.0,"EndTime":103449.0,"X":456.0,"Y":317.0}]},{"StartTime":103560.0,"Objects":[{"StartTime":103560.0,"EndTime":103560.0,"X":366.0,"Y":290.0}]},{"StartTime":103671.0,"Objects":[{"StartTime":103671.0,"EndTime":103671.0,"X":324.0,"Y":206.0}]},{"StartTime":103782.0,"Objects":[{"StartTime":103782.0,"EndTime":103782.0,"X":326.0,"Y":112.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103893.0,"EndTime":103893.0,"X":340.771759,"Y":28.293396,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":103968.0,"EndTime":103968.0,"X":326.0,"Y":112.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":104115.0,"Objects":[{"StartTime":104115.0,"EndTime":104115.0,"X":314.0,"Y":203.0}]},{"StartTime":104226.0,"Objects":[{"StartTime":104226.0,"EndTime":104226.0,"X":242.0,"Y":148.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104301.0,"EndTime":104301.0,"X":183.429764,"Y":86.40027,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":104449.0,"Objects":[{"StartTime":104449.0,"EndTime":104449.0,"X":398.0,"Y":167.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104524.0,"EndTime":104524.0,"X":472.303375,"Y":125.720352,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":104671.0,"Objects":[{"StartTime":104671.0,"EndTime":104671.0,"X":365.0,"Y":276.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104782.0,"EndTime":104782.0,"X":379.771759,"Y":359.7066,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":104857.0,"EndTime":104857.0,"X":365.0,"Y":276.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":105004.0,"Objects":[{"StartTime":105004.0,"EndTime":105004.0,"X":183.0,"Y":86.0}]},{"StartTime":105115.0,"Objects":[{"StartTime":105115.0,"EndTime":105115.0,"X":99.0,"Y":88.0}]},{"StartTime":105226.0,"Objects":[{"StartTime":105226.0,"EndTime":105226.0,"X":31.0,"Y":137.0}]},{"StartTime":105337.0,"Objects":[{"StartTime":105337.0,"EndTime":105337.0,"X":3.0,"Y":216.0}]},{"StartTime":105449.0,"Objects":[{"StartTime":105449.0,"EndTime":105449.0,"X":24.0,"Y":297.0}]},{"StartTime":105560.0,"Objects":[{"StartTime":105560.0,"EndTime":105560.0,"X":87.0,"Y":352.0}]},{"StartTime":105671.0,"Objects":[{"StartTime":105671.0,"EndTime":105671.0,"X":152.0,"Y":298.0}]},{"StartTime":105782.0,"Objects":[{"StartTime":105782.0,"EndTime":105782.0,"X":233.0,"Y":273.0}]},{"StartTime":105893.0,"Objects":[{"StartTime":105893.0,"EndTime":105893.0,"X":317.0,"Y":283.0}]},{"StartTime":106004.0,"Objects":[{"StartTime":106004.0,"EndTime":106004.0,"X":391.0,"Y":324.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":106079.0,"EndTime":106079.0,"X":475.052948,"Y":311.346863,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":106226.0,"Objects":[{"StartTime":106226.0,"EndTime":106226.0,"X":282.0,"Y":227.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":106301.0,"EndTime":106301.0,"X":198.0845,"Y":213.46524,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":106449.0,"Objects":[{"StartTime":106449.0,"EndTime":106449.0,"X":46.0,"Y":357.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":106524.0,"EndTime":106524.0,"X":129.892242,"Y":343.32193,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":106671.0,"Objects":[{"StartTime":106671.0,"EndTime":106671.0,"X":62.0,"Y":140.0}]},{"StartTime":106782.0,"Objects":[{"StartTime":106782.0,"EndTime":106782.0,"X":62.0,"Y":140.0}]},{"StartTime":106893.0,"Objects":[{"StartTime":106893.0,"EndTime":106893.0,"X":62.0,"Y":140.0}]},{"StartTime":107004.0,"Objects":[{"StartTime":107004.0,"EndTime":107004.0,"X":62.0,"Y":140.0}]},{"StartTime":107115.0,"Objects":[{"StartTime":107115.0,"EndTime":107115.0,"X":62.0,"Y":140.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":107301.0,"EndTime":107301.0,"X":230.55661,"Y":117.894211,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":107449.0,"Objects":[{"StartTime":107449.0,"EndTime":107449.0,"X":418.0,"Y":227.0}]},{"StartTime":107560.0,"Objects":[{"StartTime":107560.0,"EndTime":107560.0,"X":330.0,"Y":251.0}]},{"StartTime":107671.0,"Objects":[{"StartTime":107671.0,"EndTime":107671.0,"X":251.0,"Y":206.0}]},{"StartTime":107782.0,"Objects":[{"StartTime":107782.0,"EndTime":107782.0,"X":230.0,"Y":117.0}]},{"StartTime":107893.0,"Objects":[{"StartTime":107893.0,"EndTime":107893.0,"X":277.0,"Y":35.0}]},{"StartTime":108004.0,"Objects":[{"StartTime":108004.0,"EndTime":108004.0,"X":347.0,"Y":130.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":108079.0,"EndTime":108079.0,"X":263.20224,"Y":144.245621,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":108226.0,"Objects":[{"StartTime":108226.0,"EndTime":108226.0,"X":46.0,"Y":62.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":108412.0,"EndTime":108412.0,"X":23.9974537,"Y":230.15947,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":108560.0,"Objects":[{"StartTime":108560.0,"EndTime":108560.0,"X":215.0,"Y":312.0}]},{"StartTime":108671.0,"Objects":[{"StartTime":108671.0,"EndTime":108671.0,"X":215.0,"Y":312.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":108746.0,"EndTime":108746.0,"X":158.405411,"Y":253.670273,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":108893.0,"Objects":[{"StartTime":108893.0,"EndTime":108893.0,"X":62.0,"Y":140.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":109079.0,"EndTime":109079.0,"X":224.426849,"Y":169.542343,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":109226.0,"Objects":[{"StartTime":109226.0,"EndTime":109226.0,"X":371.0,"Y":114.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":109337.0,"EndTime":109337.0,"X":375.612762,"Y":29.12526,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":109412.0,"EndTime":109412.0,"X":371.0,"Y":114.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":109560.0,"Objects":[{"StartTime":109560.0,"EndTime":109560.0,"X":312.0,"Y":190.0}]},{"StartTime":109671.0,"Objects":[{"StartTime":109671.0,"EndTime":109671.0,"X":389.0,"Y":239.0}]},{"StartTime":109782.0,"Objects":[{"StartTime":109782.0,"EndTime":109782.0,"X":308.0,"Y":283.0}]},{"StartTime":109893.0,"Objects":[{"StartTime":109893.0,"EndTime":109893.0,"X":386.0,"Y":333.0}]},{"StartTime":110004.0,"Objects":[{"StartTime":110004.0,"EndTime":110004.0,"X":305.0,"Y":377.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":110079.0,"EndTime":110079.0,"X":231.754059,"Y":338.856445,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":110226.0,"Objects":[{"StartTime":110226.0,"EndTime":110226.0,"X":77.0,"Y":199.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":110412.0,"EndTime":110412.0,"X":223.643723,"Y":169.366547,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":110560.0,"Objects":[{"StartTime":110560.0,"EndTime":110560.0,"X":417.0,"Y":221.0}]},{"StartTime":110671.0,"Objects":[{"StartTime":110671.0,"EndTime":110671.0,"X":444.0,"Y":135.0}]},{"StartTime":110782.0,"Objects":[{"StartTime":110782.0,"EndTime":110782.0,"X":389.0,"Y":64.0}]},{"StartTime":110893.0,"Objects":[{"StartTime":110893.0,"EndTime":110893.0,"X":299.0,"Y":68.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111079.0,"EndTime":111079.0,"X":158.284866,"Y":15.6295881,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":111226.0,"Objects":[{"StartTime":111226.0,"EndTime":111226.0,"X":62.0,"Y":140.0}]},{"StartTime":111337.0,"Objects":[{"StartTime":111337.0,"EndTime":111337.0,"X":62.0,"Y":140.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111412.0,"EndTime":111412.0,"X":80.53252,"Y":222.955078,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":111560.0,"Objects":[{"StartTime":111560.0,"EndTime":111560.0,"X":262.0,"Y":296.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111635.0,"EndTime":111635.0,"X":178.5487,"Y":279.848145,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":111782.0,"Objects":[{"StartTime":111782.0,"EndTime":111782.0,"X":349.0,"Y":153.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111893.0,"EndTime":111893.0,"X":340.050018,"Y":68.4725,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":111968.0,"EndTime":111968.0,"X":349.0,"Y":153.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":112115.0,"Objects":[{"StartTime":112115.0,"EndTime":112115.0,"X":236.0,"Y":329.0}]},{"StartTime":112226.0,"Objects":[{"StartTime":112226.0,"EndTime":112226.0,"X":292.0,"Y":267.0}]},{"StartTime":112337.0,"Objects":[{"StartTime":112337.0,"EndTime":112337.0,"X":375.0,"Y":253.0}]},{"StartTime":112449.0,"Objects":[{"StartTime":112449.0,"EndTime":112449.0,"X":449.0,"Y":291.0}]},{"StartTime":112560.0,"Objects":[{"StartTime":112560.0,"EndTime":112560.0,"X":378.0,"Y":336.0}]},{"StartTime":112671.0,"Objects":[{"StartTime":112671.0,"EndTime":112671.0,"X":375.0,"Y":253.0}]},{"StartTime":112782.0,"Objects":[{"StartTime":112782.0,"EndTime":112782.0,"X":372.0,"Y":170.0}]},{"StartTime":112893.0,"Objects":[{"StartTime":112893.0,"EndTime":112893.0,"X":369.0,"Y":87.0}]},{"StartTime":113004.0,"Objects":[{"StartTime":113004.0,"EndTime":113004.0,"X":442.0,"Y":125.0}]},{"StartTime":113115.0,"Objects":[{"StartTime":113115.0,"EndTime":113115.0,"X":348.0,"Y":178.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":113190.0,"EndTime":113190.0,"X":263.3711,"Y":170.06604,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":113337.0,"Objects":[{"StartTime":113337.0,"EndTime":113337.0,"X":80.0,"Y":269.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":113412.0,"EndTime":113412.0,"X":162.128845,"Y":264.339417,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":113560.0,"Objects":[{"StartTime":113560.0,"EndTime":113560.0,"X":105.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":113635.0,"EndTime":113635.0,"X":85.68204,"Y":97.64976,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":113782.0,"Objects":[{"StartTime":113782.0,"EndTime":113782.0,"X":260.0,"Y":255.0}]},{"StartTime":113893.0,"Objects":[{"StartTime":113893.0,"EndTime":113893.0,"X":261.0,"Y":212.0}]},{"StartTime":114004.0,"Objects":[{"StartTime":114004.0,"EndTime":114004.0,"X":262.0,"Y":170.0}]},{"StartTime":114226.0,"Objects":[{"StartTime":114226.0,"EndTime":114226.0,"X":414.0,"Y":61.0}]},{"StartTime":114337.0,"Objects":[{"StartTime":114337.0,"EndTime":114337.0,"X":340.0,"Y":101.0}]},{"StartTime":114449.0,"Objects":[{"StartTime":114449.0,"EndTime":114449.0,"X":256.0,"Y":109.0}]},{"StartTime":114560.0,"Objects":[{"StartTime":114560.0,"EndTime":114560.0,"X":176.0,"Y":81.0}]},{"StartTime":114671.0,"Objects":[{"StartTime":114671.0,"EndTime":114671.0,"X":105.0,"Y":18.0}]},{"StartTime":114782.0,"Objects":[{"StartTime":114782.0,"EndTime":114782.0,"X":84.0,"Y":118.0}]},{"StartTime":114893.0,"Objects":[{"StartTime":114893.0,"EndTime":114893.0,"X":63.0,"Y":218.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":114968.0,"EndTime":114968.0,"X":145.047943,"Y":235.7841,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":115115.0,"Objects":[{"StartTime":115115.0,"EndTime":115115.0,"X":369.0,"Y":328.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":115190.0,"EndTime":115190.0,"X":286.6682,"Y":330.003754,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":115337.0,"Objects":[{"StartTime":115337.0,"EndTime":115337.0,"X":256.0,"Y":109.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":115412.0,"EndTime":115412.0,"X":353.312866,"Y":115.327446,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":115560.0,"Objects":[{"StartTime":115560.0,"EndTime":115560.0,"X":488.0,"Y":273.0}]},{"StartTime":115671.0,"Objects":[{"StartTime":115671.0,"EndTime":115671.0,"X":488.0,"Y":273.0}]},{"StartTime":115782.0,"Objects":[{"StartTime":115782.0,"EndTime":115782.0,"X":488.0,"Y":273.0}]},{"StartTime":116004.0,"Objects":[{"StartTime":116004.0,"EndTime":116004.0,"X":429.0,"Y":83.0}]},{"StartTime":116115.0,"Objects":[{"StartTime":116115.0,"EndTime":116115.0,"X":429.0,"Y":83.0}]},{"StartTime":116226.0,"Objects":[{"StartTime":116226.0,"EndTime":116226.0,"X":429.0,"Y":83.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":116281.0,"EndTime":116281.0,"X":430.78653,"Y":50.84238,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":116301.0,"EndTime":116301.0,"X":429.0,"Y":83.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":116449.0,"Objects":[{"StartTime":116449.0,"EndTime":116449.0,"X":381.0,"Y":198.0}]},{"StartTime":116560.0,"Objects":[{"StartTime":116560.0,"EndTime":116560.0,"X":381.0,"Y":198.0}]},{"StartTime":116782.0,"Objects":[{"StartTime":116782.0,"EndTime":116782.0,"X":187.0,"Y":140.0}]},{"StartTime":117004.0,"Objects":[{"StartTime":117004.0,"EndTime":117004.0,"X":102.0,"Y":323.0}]},{"StartTime":117337.0,"Objects":[{"StartTime":117337.0,"EndTime":117337.0,"X":58.0,"Y":34.0}]},{"StartTime":128004.0,"Objects":[{"StartTime":128004.0,"EndTime":128004.0,"X":380.0,"Y":124.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":128079.0,"EndTime":128079.0,"X":429.438324,"Y":121.441765,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":128226.0,"Objects":[{"StartTime":128226.0,"EndTime":128226.0,"X":396.0,"Y":256.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":128301.0,"EndTime":128301.0,"X":346.561676,"Y":258.558228,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":128449.0,"Objects":[{"StartTime":128449.0,"EndTime":128449.0,"X":104.0,"Y":200.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":128524.0,"EndTime":128524.0,"X":113.805809,"Y":249.029037,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":128671.0,"Objects":[{"StartTime":128671.0,"EndTime":128671.0,"X":172.0,"Y":104.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":128746.0,"EndTime":128746.0,"X":162.1942,"Y":54.9709663,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":128893.0,"Objects":[{"StartTime":128893.0,"EndTime":128893.0,"X":272.0,"Y":184.0}]},{"StartTime":129004.0,"Objects":[{"StartTime":129004.0,"EndTime":129004.0,"X":280.0,"Y":208.0}]},{"StartTime":129115.0,"Objects":[{"StartTime":129115.0,"EndTime":129115.0,"X":280.0,"Y":232.0}]},{"StartTime":129226.0,"Objects":[{"StartTime":129226.0,"EndTime":129226.0,"X":272.0,"Y":256.0}]},{"StartTime":129337.0,"Objects":[{"StartTime":129337.0,"EndTime":129337.0,"X":264.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":129412.0,"EndTime":129412.0,"X":309.807861,"Y":300.040955,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":129560.0,"Objects":[{"StartTime":129560.0,"EndTime":129560.0,"X":464.0,"Y":260.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":129635.0,"EndTime":129635.0,"X":418.192139,"Y":239.959061,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":129782.0,"Objects":[{"StartTime":129782.0,"EndTime":129782.0,"X":317.0,"Y":113.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":129809.0,"EndTime":129809.0,"X":332.10965,"Y":93.08272,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":129893.0,"Objects":[{"StartTime":129893.0,"EndTime":129893.0,"X":286.0,"Y":96.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":129920.0,"EndTime":129920.0,"X":294.3205,"Y":72.42524,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130004.0,"Objects":[{"StartTime":130004.0,"EndTime":130004.0,"X":252.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130031.0,"EndTime":130031.0,"X":251.305817,"Y":67.00964,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130115.0,"Objects":[{"StartTime":130115.0,"EndTime":130115.0,"X":218.0,"Y":99.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130142.0,"EndTime":130142.0,"X":209.071564,"Y":75.6487045,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130226.0,"Objects":[{"StartTime":130226.0,"EndTime":130226.0,"X":189.0,"Y":117.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130253.0,"EndTime":130253.0,"X":172.0148,"Y":98.65598,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130337.0,"Objects":[{"StartTime":130337.0,"EndTime":130337.0,"X":167.0,"Y":145.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130364.0,"EndTime":130364.0,"X":144.9221,"Y":133.271118,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130449.0,"Objects":[{"StartTime":130449.0,"EndTime":130449.0,"X":156.0,"Y":178.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130476.0,"EndTime":130476.0,"X":131.251266,"Y":174.464462,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130560.0,"Objects":[{"StartTime":130560.0,"EndTime":130560.0,"X":158.0,"Y":212.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130587.0,"EndTime":130587.0,"X":133.595322,"Y":217.423264,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130671.0,"Objects":[{"StartTime":130671.0,"EndTime":130671.0,"X":171.0,"Y":244.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130698.0,"EndTime":130698.0,"X":149.992584,"Y":257.553162,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130782.0,"Objects":[{"StartTime":130782.0,"EndTime":130782.0,"X":194.0,"Y":270.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130809.0,"EndTime":130809.0,"X":178.890335,"Y":289.917267,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":130893.0,"Objects":[{"StartTime":130893.0,"EndTime":130893.0,"X":225.0,"Y":287.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":130920.0,"EndTime":130920.0,"X":216.6795,"Y":310.574768,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":131004.0,"Objects":[{"StartTime":131004.0,"EndTime":131004.0,"X":259.0,"Y":291.0}]},{"StartTime":131337.0,"Objects":[{"StartTime":131337.0,"EndTime":131337.0,"X":259.0,"Y":291.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":131523.0,"EndTime":131523.0,"X":352.95874,"Y":282.156,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":132004.0,"Objects":[{"StartTime":132004.0,"EndTime":132004.0,"X":456.0,"Y":68.0}]},{"StartTime":132226.0,"Objects":[{"StartTime":132226.0,"EndTime":132226.0,"X":340.0,"Y":184.0}]},{"StartTime":132449.0,"Objects":[{"StartTime":132449.0,"EndTime":132449.0,"X":284.0,"Y":20.0}]},{"StartTime":132671.0,"Objects":[{"StartTime":132671.0,"EndTime":132671.0,"X":156.0,"Y":160.0}]},{"StartTime":132782.0,"Objects":[{"StartTime":132782.0,"EndTime":132782.0,"X":156.0,"Y":160.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":132968.0,"EndTime":132968.0,"X":254.257935,"Y":159.456436,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":133115.0,"Objects":[{"StartTime":133115.0,"EndTime":133115.0,"X":92.0,"Y":235.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":133190.0,"EndTime":133190.0,"X":43.1906471,"Y":245.846527,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":133337.0,"Objects":[{"StartTime":133337.0,"EndTime":133337.0,"X":281.0,"Y":303.0}]},{"StartTime":133560.0,"Objects":[{"StartTime":133560.0,"EndTime":133560.0,"X":471.0,"Y":213.0}]},{"StartTime":133782.0,"Objects":[{"StartTime":133782.0,"EndTime":133782.0,"X":281.0,"Y":303.0}]},{"StartTime":134004.0,"Objects":[{"StartTime":134004.0,"EndTime":134004.0,"X":363.0,"Y":84.0}]},{"StartTime":134226.0,"Objects":[{"StartTime":134226.0,"EndTime":134226.0,"X":241.0,"Y":162.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":134301.0,"EndTime":134301.0,"X":227.544769,"Y":113.844452,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":134449.0,"Objects":[{"StartTime":134449.0,"EndTime":134449.0,"X":164.0,"Y":204.0}]},{"StartTime":134560.0,"Objects":[{"StartTime":134560.0,"EndTime":134560.0,"X":164.0,"Y":204.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":134746.0,"EndTime":134746.0,"X":67.36322,"Y":209.315872,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":134893.0,"Objects":[{"StartTime":134893.0,"EndTime":134893.0,"X":281.0,"Y":303.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":135079.0,"EndTime":135079.0,"X":377.63678,"Y":297.684143,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":135337.0,"Objects":[{"StartTime":135337.0,"EndTime":135337.0,"X":500.0,"Y":184.0}]},{"StartTime":135560.0,"Objects":[{"StartTime":135560.0,"EndTime":135560.0,"X":465.0,"Y":11.0}]},{"StartTime":135782.0,"Objects":[{"StartTime":135782.0,"EndTime":135782.0,"X":294.0,"Y":44.0}]},{"StartTime":136004.0,"Objects":[{"StartTime":136004.0,"EndTime":136004.0,"X":327.0,"Y":217.0}]},{"StartTime":136226.0,"Objects":[{"StartTime":136226.0,"EndTime":136226.0,"X":408.0,"Y":112.0}]},{"StartTime":136337.0,"Objects":[{"StartTime":136337.0,"EndTime":136337.0,"X":408.0,"Y":112.0}]},{"StartTime":136560.0,"Objects":[{"StartTime":136560.0,"EndTime":136560.0,"X":364.0,"Y":316.0}]},{"StartTime":136671.0,"Objects":[{"StartTime":136671.0,"EndTime":136671.0,"X":364.0,"Y":316.0}]},{"StartTime":136893.0,"Objects":[{"StartTime":136893.0,"EndTime":136893.0,"X":168.0,"Y":292.0}]},{"StartTime":137115.0,"Objects":[{"StartTime":137115.0,"EndTime":137115.0,"X":48.0,"Y":212.0}]},{"StartTime":137337.0,"Objects":[{"StartTime":137337.0,"EndTime":137337.0,"X":188.0,"Y":16.0}]},{"StartTime":137560.0,"Objects":[{"StartTime":137560.0,"EndTime":137560.0,"X":176.0,"Y":156.0}]},{"StartTime":137782.0,"Objects":[{"StartTime":137782.0,"EndTime":137782.0,"X":344.0,"Y":292.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":137968.0,"EndTime":137968.0,"X":439.6384,"Y":272.7788,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":138115.0,"Objects":[{"StartTime":138115.0,"EndTime":138115.0,"X":388.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":138301.0,"EndTime":138301.0,"X":404.404022,"Y":80.73015,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":138449.0,"Objects":[{"StartTime":138449.0,"EndTime":138449.0,"X":168.0,"Y":192.0}]},{"StartTime":138560.0,"Objects":[{"StartTime":138560.0,"EndTime":138560.0,"X":168.0,"Y":192.0}]},{"StartTime":138671.0,"Objects":[{"StartTime":138671.0,"EndTime":138671.0,"X":168.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":138857.0,"EndTime":138857.0,"X":267.4309,"Y":202.65332,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":139115.0,"Objects":[{"StartTime":139115.0,"EndTime":139115.0,"X":464.0,"Y":168.0}]},{"StartTime":139337.0,"Objects":[{"StartTime":139337.0,"EndTime":139337.0,"X":344.0,"Y":292.0}]},{"StartTime":139560.0,"Objects":[{"StartTime":139560.0,"EndTime":139560.0,"X":332.0,"Y":24.0}]},{"StartTime":139782.0,"Objects":[{"StartTime":139782.0,"EndTime":139782.0,"X":288.0,"Y":368.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":139968.0,"EndTime":139968.0,"X":384.317841,"Y":377.256653,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":140226.0,"Objects":[{"StartTime":140226.0,"EndTime":140226.0,"X":224.0,"Y":16.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":140412.0,"EndTime":140412.0,"X":127.682182,"Y":6.743342,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":140671.0,"Objects":[{"StartTime":140671.0,"EndTime":140671.0,"X":80.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":140857.0,"EndTime":140857.0,"X":70.74335,"Y":320.3178,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":141115.0,"Objects":[{"StartTime":141115.0,"EndTime":141115.0,"X":400.0,"Y":152.0}]},{"StartTime":141337.0,"Objects":[{"StartTime":141337.0,"EndTime":141337.0,"X":300.0,"Y":268.0}]},{"StartTime":141560.0,"Objects":[{"StartTime":141560.0,"EndTime":141560.0,"X":460.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":141746.0,"EndTime":141746.0,"X":367.40332,"Y":63.3521423,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":142004.0,"Objects":[{"StartTime":142004.0,"EndTime":142004.0,"X":332.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":142190.0,"EndTime":142190.0,"X":346.5494,"Y":322.9359,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":142449.0,"Objects":[{"StartTime":142449.0,"EndTime":142449.0,"X":184.0,"Y":88.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":142524.0,"EndTime":142524.0,"X":178.04274,"Y":38.35616,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":142671.0,"Objects":[{"StartTime":142671.0,"EndTime":142671.0,"X":204.0,"Y":136.0}]},{"StartTime":142782.0,"Objects":[{"StartTime":142782.0,"EndTime":142782.0,"X":212.0,"Y":184.0}]},{"StartTime":142893.0,"Objects":[{"StartTime":142893.0,"EndTime":142893.0,"X":196.0,"Y":232.0}]},{"StartTime":143004.0,"Objects":[{"StartTime":143004.0,"EndTime":143004.0,"X":152.0,"Y":256.0}]},{"StartTime":143115.0,"Objects":[{"StartTime":143115.0,"EndTime":143115.0,"X":104.0,"Y":236.0}]},{"StartTime":143226.0,"Objects":[{"StartTime":143226.0,"EndTime":143226.0,"X":56.0,"Y":252.0}]},{"StartTime":143337.0,"Objects":[{"StartTime":143337.0,"EndTime":143337.0,"X":32.0,"Y":296.0}]},{"StartTime":143449.0,"Objects":[{"StartTime":143449.0,"EndTime":143449.0,"X":52.0,"Y":340.0}]},{"StartTime":143560.0,"Objects":[{"StartTime":143560.0,"EndTime":143560.0,"X":92.0,"Y":372.0}]},{"StartTime":143671.0,"Objects":[{"StartTime":143671.0,"EndTime":143671.0,"X":140.0,"Y":352.0}]},{"StartTime":143782.0,"Objects":[{"StartTime":143782.0,"EndTime":143782.0,"X":188.0,"Y":336.0}]},{"StartTime":143893.0,"Objects":[{"StartTime":143893.0,"EndTime":143893.0,"X":236.0,"Y":348.0}]},{"StartTime":144004.0,"Objects":[{"StartTime":144004.0,"EndTime":144004.0,"X":280.0,"Y":368.0}]},{"StartTime":144449.0,"Objects":[{"StartTime":144449.0,"EndTime":144449.0,"X":448.0,"Y":260.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":144476.0,"EndTime":144476.0,"X":424.591766,"Y":251.221909,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":144560.0,"Objects":[{"StartTime":144560.0,"EndTime":144560.0,"X":424.0,"Y":251.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":144587.0,"EndTime":144587.0,"X":400.357849,"Y":242.873,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":144671.0,"Objects":[{"StartTime":144671.0,"EndTime":144671.0,"X":400.0,"Y":243.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":144698.0,"EndTime":144698.0,"X":376.591766,"Y":234.221909,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":144782.0,"Objects":[{"StartTime":144782.0,"EndTime":144782.0,"X":377.0,"Y":234.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":144809.0,"EndTime":144809.0,"X":353.365662,"Y":225.850235,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":144893.0,"Objects":[{"StartTime":144893.0,"EndTime":144893.0,"X":316.0,"Y":200.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":144968.0,"EndTime":144968.0,"X":305.8368,"Y":151.497864,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":145115.0,"Objects":[{"StartTime":145115.0,"EndTime":145115.0,"X":404.0,"Y":40.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":145190.0,"EndTime":145190.0,"X":414.1632,"Y":88.50214,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":145337.0,"Objects":[{"StartTime":145337.0,"EndTime":145337.0,"X":252.0,"Y":264.0}]},{"StartTime":145449.0,"Objects":[{"StartTime":145449.0,"EndTime":145449.0,"X":252.0,"Y":264.0}]},{"StartTime":145560.0,"Objects":[{"StartTime":145560.0,"EndTime":145560.0,"X":252.0,"Y":264.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":145746.0,"EndTime":145746.0,"X":153.884521,"Y":264.3699,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":146226.0,"Objects":[{"StartTime":146226.0,"EndTime":146226.0,"X":34.0,"Y":77.0}]},{"StartTime":146449.0,"Objects":[{"StartTime":146449.0,"EndTime":146449.0,"X":54.0,"Y":230.0}]},{"StartTime":146671.0,"Objects":[{"StartTime":146671.0,"EndTime":146671.0,"X":28.0,"Y":27.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":146746.0,"EndTime":146746.0,"X":33.1014442,"Y":76.7390747,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":146893.0,"Objects":[{"StartTime":146893.0,"EndTime":146893.0,"X":54.0,"Y":230.0}]},{"StartTime":147004.0,"Objects":[{"StartTime":147004.0,"EndTime":147004.0,"X":54.0,"Y":230.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":147079.0,"EndTime":147079.0,"X":59.1014442,"Y":180.260925,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":147226.0,"Objects":[{"StartTime":147226.0,"EndTime":147226.0,"X":234.0,"Y":135.0}]},{"StartTime":147337.0,"Objects":[{"StartTime":147337.0,"EndTime":147337.0,"X":234.0,"Y":135.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":147523.0,"EndTime":147523.0,"X":333.241241,"Y":147.29538,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":147782.0,"Objects":[{"StartTime":147782.0,"EndTime":147782.0,"X":156.0,"Y":190.0}]},{"StartTime":148004.0,"Objects":[{"StartTime":148004.0,"EndTime":148004.0,"X":333.0,"Y":147.0}]},{"StartTime":148226.0,"Objects":[{"StartTime":148226.0,"EndTime":148226.0,"X":133.0,"Y":120.0}]},{"StartTime":148449.0,"Objects":[{"StartTime":148449.0,"EndTime":148449.0,"X":358.0,"Y":65.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":148635.0,"EndTime":148635.0,"X":260.9272,"Y":89.01802,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":148782.0,"Objects":[{"StartTime":148782.0,"EndTime":148782.0,"X":333.0,"Y":147.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":148968.0,"EndTime":148968.0,"X":432.241241,"Y":159.29538,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":149115.0,"Objects":[{"StartTime":149115.0,"EndTime":149115.0,"X":321.0,"Y":275.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":149190.0,"EndTime":149190.0,"X":306.130829,"Y":227.2621,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":149337.0,"Objects":[{"StartTime":149337.0,"EndTime":149337.0,"X":462.0,"Y":165.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":149523.0,"EndTime":149523.0,"X":431.9714,"Y":260.384918,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":149782.0,"Objects":[{"StartTime":149782.0,"EndTime":149782.0,"X":393.0,"Y":83.0}]},{"StartTime":150004.0,"Objects":[{"StartTime":150004.0,"EndTime":150004.0,"X":431.0,"Y":260.0}]},{"StartTime":150226.0,"Objects":[{"StartTime":150226.0,"EndTime":150226.0,"X":221.0,"Y":288.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":150412.0,"EndTime":150412.0,"X":320.083,"Y":274.488678,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":150560.0,"Objects":[{"StartTime":150560.0,"EndTime":150560.0,"X":190.0,"Y":296.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":150635.0,"EndTime":150635.0,"X":175.130814,"Y":248.2621,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":150782.0,"Objects":[{"StartTime":150782.0,"EndTime":150782.0,"X":334.0,"Y":226.0}]},{"StartTime":150893.0,"Objects":[{"StartTime":150893.0,"EndTime":150893.0,"X":334.0,"Y":226.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":150968.0,"EndTime":150968.0,"X":319.130829,"Y":273.7379,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":151115.0,"Objects":[{"StartTime":151115.0,"EndTime":151115.0,"X":238.0,"Y":135.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":151190.0,"EndTime":151190.0,"X":252.869186,"Y":182.7379,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":151337.0,"Objects":[{"StartTime":151337.0,"EndTime":151337.0,"X":190.0,"Y":296.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":151412.0,"EndTime":151412.0,"X":204.869186,"Y":343.7379,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":151560.0,"Objects":[{"StartTime":151560.0,"EndTime":151560.0,"X":118.0,"Y":72.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":151635.0,"EndTime":151635.0,"X":132.869186,"Y":119.7379,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":151782.0,"Objects":[{"StartTime":151782.0,"EndTime":151782.0,"X":24.0,"Y":221.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":151857.0,"EndTime":151857.0,"X":38.8691826,"Y":268.7379,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":152004.0,"Objects":[{"StartTime":152004.0,"EndTime":152004.0,"X":242.0,"Y":367.0}]},{"StartTime":152115.0,"Objects":[{"StartTime":152115.0,"EndTime":152115.0,"X":276.0,"Y":362.0}]},{"StartTime":152226.0,"Objects":[{"StartTime":152226.0,"EndTime":152226.0,"X":311.0,"Y":357.0}]},{"StartTime":152337.0,"Objects":[{"StartTime":152337.0,"EndTime":152337.0,"X":346.0,"Y":353.0}]},{"StartTime":152449.0,"Objects":[{"StartTime":152449.0,"EndTime":152449.0,"X":386.0,"Y":338.0}]},{"StartTime":152560.0,"Objects":[{"StartTime":152560.0,"EndTime":152560.0,"X":337.0,"Y":327.0}]},{"StartTime":152671.0,"Objects":[{"StartTime":152671.0,"EndTime":152671.0,"X":288.0,"Y":317.0}]},{"StartTime":152782.0,"Objects":[{"StartTime":152782.0,"EndTime":152782.0,"X":239.0,"Y":306.0}]},{"StartTime":152893.0,"Objects":[{"StartTime":152893.0,"EndTime":152893.0,"X":190.0,"Y":296.0}]},{"StartTime":153337.0,"Objects":[{"StartTime":153337.0,"EndTime":153337.0,"X":190.0,"Y":296.0}]},{"StartTime":153560.0,"Objects":[{"StartTime":153560.0,"EndTime":153560.0,"X":241.0,"Y":77.0}]},{"StartTime":153782.0,"Objects":[{"StartTime":153782.0,"EndTime":153782.0,"X":200.0,"Y":262.0}]},{"StartTime":154004.0,"Objects":[{"StartTime":154004.0,"EndTime":154004.0,"X":447.0,"Y":200.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":154190.0,"EndTime":154190.0,"X":348.433533,"Y":183.128265,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":154449.0,"Objects":[{"StartTime":154449.0,"EndTime":154449.0,"X":97.0,"Y":119.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":154524.0,"EndTime":154524.0,"X":146.362061,"Y":111.038376,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":154671.0,"Objects":[{"StartTime":154671.0,"EndTime":154671.0,"X":348.0,"Y":183.0}]},{"StartTime":154893.0,"Objects":[{"StartTime":154893.0,"EndTime":154893.0,"X":151.0,"Y":210.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":155079.0,"EndTime":155079.0,"X":145.955917,"Y":110.1273,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":155337.0,"Objects":[{"StartTime":155337.0,"EndTime":155337.0,"X":353.0,"Y":83.0}]},{"StartTime":155449.0,"Objects":[{"StartTime":155449.0,"EndTime":155449.0,"X":350.0,"Y":132.0}]},{"StartTime":155560.0,"Objects":[{"StartTime":155560.0,"EndTime":155560.0,"X":347.0,"Y":182.0}]},{"StartTime":155782.0,"Objects":[{"StartTime":155782.0,"EndTime":155782.0,"X":31.0,"Y":78.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":156190.0,"EndTime":156190.0,"X":144.98584,"Y":110.023438,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":156449.0,"Objects":[{"StartTime":156449.0,"EndTime":156449.0,"X":113.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":156524.0,"EndTime":156524.0,"X":111.040718,"Y":230.038391,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":156671.0,"Objects":[{"StartTime":156671.0,"EndTime":156671.0,"X":145.0,"Y":110.0}]},{"StartTime":156893.0,"Objects":[{"StartTime":156893.0,"EndTime":156893.0,"X":108.0,"Y":312.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":156968.0,"EndTime":156968.0,"X":58.4584923,"Y":305.244354,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":157115.0,"Objects":[{"StartTime":157115.0,"EndTime":157115.0,"X":211.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":157190.0,"EndTime":157190.0,"X":260.5415,"Y":214.755661,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":157337.0,"Objects":[{"StartTime":157337.0,"EndTime":157337.0,"X":414.0,"Y":288.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":157392.0,"EndTime":157392.0,"X":389.2185,"Y":289.835663,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":157448.0,"EndTime":157448.0,"X":413.849823,"Y":288.011139,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":157467.0,"EndTime":157467.0,"X":389.0683,"Y":289.8468,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":157560.0,"Objects":[{"StartTime":157560.0,"EndTime":157560.0,"X":290.0,"Y":340.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":157615.0,"EndTime":157615.0,"X":314.7762,"Y":341.905884,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":157671.0,"EndTime":157671.0,"X":290.150146,"Y":340.011536,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":157690.0,"EndTime":157690.0,"X":314.926361,"Y":341.917419,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":157782.0,"Objects":[{"StartTime":157782.0,"EndTime":157782.0,"X":414.0,"Y":288.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":157857.0,"EndTime":157857.0,"X":419.1956,"Y":238.270676,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":158004.0,"Objects":[{"StartTime":158004.0,"EndTime":158004.0,"X":315.0,"Y":130.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":158079.0,"EndTime":158079.0,"X":328.026154,"Y":178.273376,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":158226.0,"Objects":[{"StartTime":158226.0,"EndTime":158226.0,"X":492.0,"Y":100.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":158301.0,"EndTime":158301.0,"X":442.4585,"Y":93.24434,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":158449.0,"Objects":[{"StartTime":158449.0,"EndTime":158449.0,"X":214.0,"Y":158.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":158524.0,"EndTime":158524.0,"X":204.3489,"Y":108.940277,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":158671.0,"Objects":[{"StartTime":158671.0,"EndTime":158671.0,"X":342.0,"Y":13.0}]},{"StartTime":158782.0,"Objects":[{"StartTime":158782.0,"EndTime":158782.0,"X":342.0,"Y":13.0}]},{"StartTime":158893.0,"Objects":[{"StartTime":158893.0,"EndTime":158893.0,"X":342.0,"Y":13.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":158948.0,"EndTime":158948.0,"X":339.757,"Y":37.673027,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":158968.0,"EndTime":158968.0,"X":342.0,"Y":13.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":159115.0,"Objects":[{"StartTime":159115.0,"EndTime":159115.0,"X":324.701019,"Y":148.701}]},{"StartTime":159171.0,"Objects":[{"StartTime":159171.0,"EndTime":159171.0,"X":328.3505,"Y":152.35051}]},{"StartTime":159226.0,"Objects":[{"StartTime":159226.0,"EndTime":159226.0,"X":332.0,"Y":156.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":159301.0,"EndTime":159301.0,"X":380.019165,"Y":166.010254,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":159449.0,"Objects":[{"StartTime":159449.0,"EndTime":159449.0,"X":456.0,"Y":260.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":159524.0,"EndTime":159524.0,"X":445.989746,"Y":308.019165,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":159671.0,"Objects":[{"StartTime":159671.0,"EndTime":159671.0,"X":340.0,"Y":368.0}]},{"StartTime":159782.0,"Objects":[{"StartTime":159782.0,"EndTime":159782.0,"X":340.0,"Y":368.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":159857.0,"EndTime":159857.0,"X":291.980835,"Y":357.989746,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":160004.0,"Objects":[{"StartTime":160004.0,"EndTime":160004.0,"X":44.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":160190.0,"EndTime":160190.0,"X":195.181229,"Y":229.801666,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":160337.0,"Objects":[{"StartTime":160337.0,"EndTime":160337.0,"X":324.0,"Y":28.0}]},{"StartTime":160449.0,"Objects":[{"StartTime":160449.0,"EndTime":160449.0,"X":264.0,"Y":100.0}]},{"StartTime":160560.0,"Objects":[{"StartTime":160560.0,"EndTime":160560.0,"X":312.0,"Y":180.0}]},{"StartTime":160671.0,"Objects":[{"StartTime":160671.0,"EndTime":160671.0,"X":404.0,"Y":164.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":160857.0,"EndTime":160857.0,"X":352.153137,"Y":316.6705,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":161004.0,"Objects":[{"StartTime":161004.0,"EndTime":161004.0,"X":184.0,"Y":328.0}]},{"StartTime":161115.0,"Objects":[{"StartTime":161115.0,"EndTime":161115.0,"X":96.0,"Y":352.0}]},{"StartTime":161226.0,"Objects":[{"StartTime":161226.0,"EndTime":161226.0,"X":32.0,"Y":280.0}]},{"StartTime":161337.0,"Objects":[{"StartTime":161337.0,"EndTime":161337.0,"X":56.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":161412.0,"EndTime":161412.0,"X":48.3044624,"Y":107.349075,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":161560.0,"Objects":[{"StartTime":161560.0,"EndTime":161560.0,"X":184.0,"Y":24.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":161746.0,"EndTime":161746.0,"X":321.449677,"Y":63.5203857,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":161893.0,"Objects":[{"StartTime":161893.0,"EndTime":161893.0,"X":476.0,"Y":176.0}]},{"StartTime":162004.0,"Objects":[{"StartTime":162004.0,"EndTime":162004.0,"X":384.0,"Y":196.0}]},{"StartTime":162115.0,"Objects":[{"StartTime":162115.0,"EndTime":162115.0,"X":356.0,"Y":280.0}]},{"StartTime":162226.0,"Objects":[{"StartTime":162226.0,"EndTime":162226.0,"X":416.0,"Y":352.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":162412.0,"EndTime":162412.0,"X":246.175339,"Y":359.7193,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":162560.0,"Objects":[{"StartTime":162560.0,"EndTime":162560.0,"X":64.0,"Y":312.0}]},{"StartTime":162671.0,"Objects":[{"StartTime":162671.0,"EndTime":162671.0,"X":128.0,"Y":244.0}]},{"StartTime":162782.0,"Objects":[{"StartTime":162782.0,"EndTime":162782.0,"X":152.0,"Y":156.0}]},{"StartTime":162893.0,"Objects":[{"StartTime":162893.0,"EndTime":162893.0,"X":96.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":162968.0,"EndTime":162968.0,"X":150.808136,"Y":18.1909065,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":163115.0,"Objects":[{"StartTime":163115.0,"EndTime":163115.0,"X":361.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":163190.0,"EndTime":163190.0,"X":415.8814,"Y":79.67221,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":163337.0,"Objects":[{"StartTime":163337.0,"EndTime":163337.0,"X":256.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":163412.0,"EndTime":163412.0,"X":256.0,"Y":139.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":163560.0,"Objects":[{"StartTime":163560.0,"EndTime":163560.0,"X":356.0,"Y":280.0}]},{"StartTime":163671.0,"Objects":[{"StartTime":163671.0,"EndTime":163671.0,"X":356.0,"Y":280.0}]},{"StartTime":163782.0,"Objects":[{"StartTime":163782.0,"EndTime":163782.0,"X":356.0,"Y":280.0}]},{"StartTime":163893.0,"Objects":[{"StartTime":163893.0,"EndTime":163893.0,"X":356.0,"Y":280.0}]},{"StartTime":164004.0,"Objects":[{"StartTime":164004.0,"EndTime":164004.0,"X":356.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":164190.0,"EndTime":164190.0,"X":216.722748,"Y":322.381317,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":164337.0,"Objects":[{"StartTime":164337.0,"EndTime":164337.0,"X":124.0,"Y":344.0}]},{"StartTime":164449.0,"Objects":[{"StartTime":164449.0,"EndTime":164449.0,"X":92.0,"Y":256.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":164635.0,"EndTime":164635.0,"X":116.255264,"Y":109.43438,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":164782.0,"Objects":[{"StartTime":164782.0,"EndTime":164782.0,"X":180.0,"Y":184.0}]},{"StartTime":164893.0,"Objects":[{"StartTime":164893.0,"EndTime":164893.0,"X":180.0,"Y":184.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":164968.0,"EndTime":164968.0,"X":264.749634,"Y":177.4808,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":165115.0,"Objects":[{"StartTime":165115.0,"EndTime":165115.0,"X":500.0,"Y":88.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":165301.0,"EndTime":165301.0,"X":345.542877,"Y":109.4133,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":165449.0,"Objects":[{"StartTime":165449.0,"EndTime":165449.0,"X":320.0,"Y":236.0}]},{"StartTime":165560.0,"Objects":[{"StartTime":165560.0,"EndTime":165560.0,"X":320.0,"Y":236.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":165635.0,"EndTime":165635.0,"X":330.12735,"Y":320.394531,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":165782.0,"Objects":[{"StartTime":165782.0,"EndTime":165782.0,"X":432.0,"Y":156.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":165968.0,"EndTime":165968.0,"X":355.944641,"Y":21.43514,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":166115.0,"Objects":[{"StartTime":166115.0,"EndTime":166115.0,"X":216.0,"Y":64.0}]},{"StartTime":166226.0,"Objects":[{"StartTime":166226.0,"EndTime":166226.0,"X":216.0,"Y":64.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":166301.0,"EndTime":166301.0,"X":131.188263,"Y":69.65411,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":166449.0,"Objects":[{"StartTime":166449.0,"EndTime":166449.0,"X":143.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":166524.0,"EndTime":166524.0,"X":121.902931,"Y":286.720856,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":166671.0,"Objects":[{"StartTime":166671.0,"EndTime":166671.0,"X":312.0,"Y":291.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":166746.0,"EndTime":166746.0,"X":233.138763,"Y":269.922852,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":166893.0,"Objects":[{"StartTime":166893.0,"EndTime":166893.0,"X":379.0,"Y":185.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":166968.0,"EndTime":166968.0,"X":458.244446,"Y":204.810349,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":167115.0,"Objects":[{"StartTime":167115.0,"EndTime":167115.0,"X":456.0,"Y":64.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":167301.0,"EndTime":167301.0,"X":298.885925,"Y":68.90503,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":167449.0,"Objects":[{"StartTime":167449.0,"EndTime":167449.0,"X":112.0,"Y":148.0}]},{"StartTime":167560.0,"Objects":[{"StartTime":167560.0,"EndTime":167560.0,"X":160.0,"Y":228.0}]},{"StartTime":167671.0,"Objects":[{"StartTime":167671.0,"EndTime":167671.0,"X":248.0,"Y":256.0}]},{"StartTime":167782.0,"Objects":[{"StartTime":167782.0,"EndTime":167782.0,"X":336.0,"Y":228.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":167968.0,"EndTime":167968.0,"X":477.379944,"Y":264.4144,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":168115.0,"Objects":[{"StartTime":168115.0,"EndTime":168115.0,"X":392.0,"Y":308.0}]},{"StartTime":168226.0,"Objects":[{"StartTime":168226.0,"EndTime":168226.0,"X":336.0,"Y":228.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":168301.0,"EndTime":168301.0,"X":332.7332,"Y":143.0628,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":168449.0,"Objects":[{"StartTime":168449.0,"EndTime":168449.0,"X":448.0,"Y":32.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":168524.0,"EndTime":168524.0,"X":451.2668,"Y":116.937195,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":168671.0,"Objects":[{"StartTime":168671.0,"EndTime":168671.0,"X":392.0,"Y":308.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":168857.0,"EndTime":168857.0,"X":238.278824,"Y":295.8506,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":169004.0,"Objects":[{"StartTime":169004.0,"EndTime":169004.0,"X":156.0,"Y":340.0}]},{"StartTime":169115.0,"Objects":[{"StartTime":169115.0,"EndTime":169115.0,"X":88.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":169190.0,"EndTime":169190.0,"X":117.84549,"Y":192.412018,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":169337.0,"Objects":[{"StartTime":169337.0,"EndTime":169337.0,"X":88.0,"Y":56.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":169523.0,"EndTime":169523.0,"X":241.888382,"Y":84.10678,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":169671.0,"Objects":[{"StartTime":169671.0,"EndTime":169671.0,"X":416.0,"Y":28.0}]},{"StartTime":169782.0,"Objects":[{"StartTime":169782.0,"EndTime":169782.0,"X":360.0,"Y":104.0}]},{"StartTime":169893.0,"Objects":[{"StartTime":169893.0,"EndTime":169893.0,"X":352.0,"Y":196.0}]},{"StartTime":170004.0,"Objects":[{"StartTime":170004.0,"EndTime":170004.0,"X":396.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":170079.0,"EndTime":170079.0,"X":421.4277,"Y":356.52002,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":170226.0,"Objects":[{"StartTime":170226.0,"EndTime":170226.0,"X":272.0,"Y":296.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":170301.0,"EndTime":170301.0,"X":246.572281,"Y":219.47998,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":170449.0,"Objects":[{"StartTime":170449.0,"EndTime":170449.0,"X":68.0,"Y":288.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":170524.0,"EndTime":170524.0,"X":150.606369,"Y":280.9753,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":170671.0,"Objects":[{"StartTime":170671.0,"EndTime":170671.0,"X":324.0,"Y":148.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":170857.0,"EndTime":170857.0,"X":216.197983,"Y":92.1464157,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":171004.0,"Objects":[{"StartTime":171004.0,"EndTime":171004.0,"X":0.0,"Y":244.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":171190.0,"EndTime":171190.0,"X":168.960251,"Y":262.773346,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":171337.0,"Objects":[{"StartTime":171337.0,"EndTime":171337.0,"X":460.0,"Y":240.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":171412.0,"EndTime":171412.0,"X":461.787476,"Y":324.6682,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":171560.0,"Objects":[{"StartTime":171560.0,"EndTime":171560.0,"X":336.0,"Y":284.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":171635.0,"EndTime":171635.0,"X":334.2125,"Y":199.331787,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":171782.0,"Objects":[{"StartTime":171782.0,"EndTime":171782.0,"X":388.0,"Y":40.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":171857.0,"EndTime":171857.0,"X":417.406158,"Y":117.402733,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":172004.0,"Objects":[{"StartTime":172004.0,"EndTime":172004.0,"X":204.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":172079.0,"EndTime":172079.0,"X":287.906921,"Y":88.144165,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":172226.0,"Objects":[{"StartTime":172226.0,"EndTime":172226.0,"X":208.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":172301.0,"EndTime":172301.0,"X":124.093079,"Y":183.855835,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":172449.0,"Objects":[{"StartTime":172449.0,"EndTime":172449.0,"X":256.0,"Y":48.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":172524.0,"EndTime":172524.0,"X":339.312347,"Y":63.6145,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":172671.0,"Objects":[{"StartTime":172671.0,"EndTime":172671.0,"X":400.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":172746.0,"EndTime":172746.0,"X":384.385468,"Y":275.312347,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":172893.0,"Objects":[{"StartTime":172893.0,"EndTime":172893.0,"X":256.0,"Y":336.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":172968.0,"EndTime":172968.0,"X":172.687653,"Y":320.3855,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":173115.0,"Objects":[{"StartTime":173115.0,"EndTime":173115.0,"X":112.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":173190.0,"EndTime":173190.0,"X":127.6145,"Y":108.687653,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":173337.0,"Objects":[{"StartTime":173337.0,"EndTime":173337.0,"X":400.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":173412.0,"EndTime":173412.0,"X":384.385468,"Y":275.312347,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":173560.0,"Objects":[{"StartTime":173560.0,"EndTime":173560.0,"X":256.0,"Y":48.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":173635.0,"EndTime":173635.0,"X":339.312347,"Y":63.6145,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":173782.0,"Objects":[{"StartTime":173782.0,"EndTime":173782.0,"X":172.0,"Y":320.0}]},{"StartTime":173893.0,"Objects":[{"StartTime":173893.0,"EndTime":173893.0,"X":117.0,"Y":245.0}]},{"StartTime":174004.0,"Objects":[{"StartTime":174004.0,"EndTime":174004.0,"X":116.0,"Y":153.0}]},{"StartTime":174115.0,"Objects":[{"StartTime":174115.0,"EndTime":174115.0,"X":169.0,"Y":78.0}]},{"StartTime":174226.0,"Objects":[{"StartTime":174226.0,"EndTime":174226.0,"X":256.0,"Y":48.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":174412.0,"EndTime":174412.0,"X":339.468872,"Y":164.666275,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":174560.0,"Objects":[{"StartTime":174560.0,"EndTime":174560.0,"X":168.0,"Y":268.0}]},{"StartTime":174671.0,"Objects":[{"StartTime":174671.0,"EndTime":174671.0,"X":238.0,"Y":207.0}]},{"StartTime":174782.0,"Objects":[{"StartTime":174782.0,"EndTime":174782.0,"X":329.0,"Y":194.0}]},{"StartTime":174893.0,"Objects":[{"StartTime":174893.0,"EndTime":174893.0,"X":413.0,"Y":232.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":175004.0,"EndTime":175004.0,"X":424.06662,"Y":316.27652,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":175079.0,"EndTime":175079.0,"X":413.0,"Y":232.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":175226.0,"Objects":[{"StartTime":175226.0,"EndTime":175226.0,"X":344.0,"Y":150.0}]},{"StartTime":175337.0,"Objects":[{"StartTime":175337.0,"EndTime":175337.0,"X":344.0,"Y":150.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":175412.0,"EndTime":175412.0,"X":260.536621,"Y":133.910675,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":175560.0,"Objects":[{"StartTime":175560.0,"EndTime":175560.0,"X":478.0,"Y":62.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":175635.0,"EndTime":175635.0,"X":468.068481,"Y":146.417816,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":175782.0,"Objects":[{"StartTime":175782.0,"EndTime":175782.0,"X":322.0,"Y":328.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":175968.0,"EndTime":175968.0,"X":170.064575,"Y":292.1422,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":176115.0,"Objects":[{"StartTime":176115.0,"EndTime":176115.0,"X":50.0,"Y":366.0}]},{"StartTime":176226.0,"Objects":[{"StartTime":176226.0,"EndTime":176226.0,"X":91.0,"Y":283.0}]},{"StartTime":176337.0,"Objects":[{"StartTime":176337.0,"EndTime":176337.0,"X":81.0,"Y":191.0}]},{"StartTime":176449.0,"Objects":[{"StartTime":176449.0,"EndTime":176449.0,"X":24.0,"Y":119.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":176635.0,"EndTime":176635.0,"X":175.935425,"Y":154.857788,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":176782.0,"Objects":[{"StartTime":176782.0,"EndTime":176782.0,"X":327.0,"Y":79.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":176968.0,"EndTime":176968.0,"X":306.514526,"Y":247.7612,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":177115.0,"Objects":[{"StartTime":177115.0,"EndTime":177115.0,"X":125.0,"Y":323.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":177190.0,"EndTime":177190.0,"X":203.49823,"Y":298.039764,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":177337.0,"Objects":[{"StartTime":177337.0,"EndTime":177337.0,"X":389.0,"Y":256.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":177412.0,"EndTime":177412.0,"X":306.045349,"Y":247.667252,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":177560.0,"Objects":[{"StartTime":177560.0,"EndTime":177560.0,"X":180.0,"Y":72.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":177635.0,"EndTime":177635.0,"X":249.856644,"Y":116.309669,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":177782.0,"Objects":[{"StartTime":177782.0,"EndTime":177782.0,"X":66.0,"Y":257.0}]},{"StartTime":177893.0,"Objects":[{"StartTime":177893.0,"EndTime":177893.0,"X":64.0,"Y":235.0}]},{"StartTime":178004.0,"Objects":[{"StartTime":178004.0,"EndTime":178004.0,"X":63.0,"Y":214.0}]},{"StartTime":178115.0,"Objects":[{"StartTime":178115.0,"EndTime":178115.0,"X":62.0,"Y":193.0}]},{"StartTime":178226.0,"Objects":[{"StartTime":178226.0,"EndTime":178226.0,"X":61.0,"Y":172.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":178412.0,"EndTime":178412.0,"X":146.4032,"Y":243.470139,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":178560.0,"Objects":[{"StartTime":178560.0,"EndTime":178560.0,"X":4.0,"Y":96.0}]},{"StartTime":178671.0,"Objects":[{"StartTime":178671.0,"EndTime":178671.0,"X":81.0,"Y":61.0}]},{"StartTime":178782.0,"Objects":[{"StartTime":178782.0,"EndTime":178782.0,"X":164.0,"Y":73.0}]},{"StartTime":178893.0,"Objects":[{"StartTime":178893.0,"EndTime":178893.0,"X":227.0,"Y":128.0}]},{"StartTime":179004.0,"Objects":[{"StartTime":179004.0,"EndTime":179004.0,"X":251.0,"Y":209.0}]},{"StartTime":179115.0,"Objects":[{"StartTime":179115.0,"EndTime":179115.0,"X":228.0,"Y":289.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":179190.0,"EndTime":179190.0,"X":215.417068,"Y":207.655624,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":179337.0,"Objects":[{"StartTime":179337.0,"EndTime":179337.0,"X":401.0,"Y":62.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":179523.0,"EndTime":179523.0,"X":254.5407,"Y":108.325722,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":179671.0,"Objects":[{"StartTime":179671.0,"EndTime":179671.0,"X":93.0,"Y":212.0}]},{"StartTime":179782.0,"Objects":[{"StartTime":179782.0,"EndTime":179782.0,"X":93.0,"Y":212.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":179857.0,"EndTime":179857.0,"X":172.022354,"Y":235.615982,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":180004.0,"Objects":[{"StartTime":180004.0,"EndTime":180004.0,"X":334.0,"Y":366.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":180190.0,"EndTime":180190.0,"X":344.786163,"Y":196.393326,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":180337.0,"Objects":[{"StartTime":180337.0,"EndTime":180337.0,"X":311.0,"Y":33.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":180448.0,"EndTime":180448.0,"X":230.004761,"Y":19.62839,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":180523.0,"EndTime":180523.0,"X":311.0,"Y":33.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":180671.0,"Objects":[{"StartTime":180671.0,"EndTime":180671.0,"X":289.0,"Y":212.0}]},{"StartTime":180782.0,"Objects":[{"StartTime":180782.0,"EndTime":180782.0,"X":196.0,"Y":198.0}]},{"StartTime":180893.0,"Objects":[{"StartTime":180893.0,"EndTime":180893.0,"X":255.0,"Y":124.0}]},{"StartTime":181004.0,"Objects":[{"StartTime":181004.0,"EndTime":181004.0,"X":301.0,"Y":221.0}]},{"StartTime":181115.0,"Objects":[{"StartTime":181115.0,"EndTime":181115.0,"X":181.0,"Y":203.0}]},{"StartTime":181226.0,"Objects":[{"StartTime":181226.0,"EndTime":181226.0,"X":257.0,"Y":108.0}]},{"StartTime":181337.0,"Objects":[{"StartTime":181337.0,"EndTime":181337.0,"X":372.0,"Y":147.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":181523.0,"EndTime":181523.0,"X":375.6031,"Y":297.656464,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":181671.0,"Objects":[{"StartTime":181671.0,"EndTime":181671.0,"X":163.0,"Y":238.0}]},{"StartTime":181782.0,"Objects":[{"StartTime":181782.0,"EndTime":181782.0,"X":74.0,"Y":225.0}]},{"StartTime":181893.0,"Objects":[{"StartTime":181893.0,"EndTime":181893.0,"X":18.0,"Y":294.0}]},{"StartTime":182004.0,"Objects":[{"StartTime":182004.0,"EndTime":182004.0,"X":50.0,"Y":378.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":182190.0,"EndTime":182190.0,"X":210.439133,"Y":359.855164,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":182337.0,"Objects":[{"StartTime":182337.0,"EndTime":182337.0,"X":355.0,"Y":315.0}]},{"StartTime":182449.0,"Objects":[{"StartTime":182449.0,"EndTime":182449.0,"X":355.0,"Y":315.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":182524.0,"EndTime":182524.0,"X":349.108246,"Y":231.35257,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":182671.0,"Objects":[{"StartTime":182671.0,"EndTime":182671.0,"X":423.0,"Y":86.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":182746.0,"EndTime":182746.0,"X":428.891754,"Y":169.64743,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":182893.0,"Objects":[{"StartTime":182893.0,"EndTime":182893.0,"X":214.0,"Y":113.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":183079.0,"EndTime":183079.0,"X":46.0509644,"Y":139.327148,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":183226.0,"Objects":[{"StartTime":183226.0,"EndTime":183226.0,"X":181.0,"Y":224.0}]},{"StartTime":183337.0,"Objects":[{"StartTime":183337.0,"EndTime":183337.0,"X":264.0,"Y":237.0}]},{"StartTime":183449.0,"Objects":[{"StartTime":183449.0,"EndTime":183449.0,"X":348.0,"Y":250.0}]},{"StartTime":183560.0,"Objects":[{"StartTime":183560.0,"EndTime":183560.0,"X":431.0,"Y":262.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":183671.0,"EndTime":183671.0,"X":448.007324,"Y":343.90332,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":183746.0,"EndTime":183746.0,"X":431.0,"Y":262.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":183893.0,"Objects":[{"StartTime":183893.0,"EndTime":183893.0,"X":282.0,"Y":68.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":184079.0,"EndTime":184079.0,"X":216.012039,"Y":194.120026,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":184226.0,"Objects":[{"StartTime":184226.0,"EndTime":184226.0,"X":391.0,"Y":175.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":184301.0,"EndTime":184301.0,"X":307.156525,"Y":161.0261,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":184449.0,"Objects":[{"StartTime":184449.0,"EndTime":184449.0,"X":132.0,"Y":298.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":184524.0,"EndTime":184524.0,"X":185.825211,"Y":232.213623,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":184671.0,"Objects":[{"StartTime":184671.0,"EndTime":184671.0,"X":154.0,"Y":12.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":184746.0,"EndTime":184746.0,"X":183.291321,"Y":91.7936,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":184893.0,"Objects":[{"StartTime":184893.0,"EndTime":184893.0,"X":395.0,"Y":82.0}]},{"StartTime":185004.0,"Objects":[{"StartTime":185004.0,"EndTime":185004.0,"X":473.0,"Y":129.0}]},{"StartTime":185115.0,"Objects":[{"StartTime":185115.0,"EndTime":185115.0,"X":391.0,"Y":175.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":185190.0,"EndTime":185190.0,"X":307.55957,"Y":170.7346,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":185337.0,"Objects":[{"StartTime":185337.0,"EndTime":185337.0,"X":105.0,"Y":112.0}]},{"StartTime":185449.0,"Objects":[{"StartTime":185449.0,"EndTime":185449.0,"X":26.0,"Y":158.0}]},{"StartTime":185560.0,"Objects":[{"StartTime":185560.0,"EndTime":185560.0,"X":108.0,"Y":204.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":185635.0,"EndTime":185635.0,"X":191.442719,"Y":198.202209,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":185782.0,"Objects":[{"StartTime":185782.0,"EndTime":185782.0,"X":314.0,"Y":35.0}]},{"StartTime":185893.0,"Objects":[{"StartTime":185893.0,"EndTime":185893.0,"X":230.0,"Y":71.0}]},{"StartTime":186004.0,"Objects":[{"StartTime":186004.0,"EndTime":186004.0,"X":188.0,"Y":153.0}]},{"StartTime":186115.0,"Objects":[{"StartTime":186115.0,"EndTime":186115.0,"X":206.0,"Y":243.0}]},{"StartTime":186226.0,"Objects":[{"StartTime":186226.0,"EndTime":186226.0,"X":277.0,"Y":300.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":186301.0,"EndTime":186301.0,"X":359.753845,"Y":313.858582,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":186449.0,"Objects":[{"StartTime":186449.0,"EndTime":186449.0,"X":469.0,"Y":143.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":186524.0,"EndTime":186524.0,"X":388.1845,"Y":159.058762,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":186671.0,"Objects":[{"StartTime":186671.0,"EndTime":186671.0,"X":180.0,"Y":96.0}]},{"StartTime":186782.0,"Objects":[{"StartTime":186782.0,"EndTime":186782.0,"X":180.0,"Y":96.0}]},{"StartTime":186893.0,"Objects":[{"StartTime":186893.0,"EndTime":186893.0,"X":180.0,"Y":96.0}]},{"StartTime":187115.0,"Objects":[{"StartTime":187115.0,"EndTime":187115.0,"X":333.0,"Y":236.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":187190.0,"EndTime":187190.0,"X":343.066284,"Y":151.598175,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":187337.0,"Objects":[{"StartTime":187337.0,"EndTime":187337.0,"X":404.0,"Y":53.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":187392.0,"EndTime":187392.0,"X":398.1796,"Y":11.2869987,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":187412.0,"EndTime":187412.0,"X":404.0,"Y":53.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":187560.0,"Objects":[{"StartTime":187560.0,"EndTime":187560.0,"X":426.0,"Y":195.0}]},{"StartTime":187671.0,"Objects":[{"StartTime":187671.0,"EndTime":187671.0,"X":426.0,"Y":195.0}]},{"StartTime":187893.0,"Objects":[{"StartTime":187893.0,"EndTime":187893.0,"X":240.0,"Y":159.0}]},{"StartTime":188115.0,"Objects":[{"StartTime":188115.0,"EndTime":188115.0,"X":350.0,"Y":339.0}]},{"StartTime":188449.0,"Objects":[{"StartTime":188449.0,"EndTime":188449.0,"X":70.0,"Y":296.0}]},{"StartTime":195560.0,"Objects":[{"StartTime":195560.0,"EndTime":195560.0,"X":432.0,"Y":192.0}]},{"StartTime":195671.0,"Objects":[{"StartTime":195671.0,"EndTime":195671.0,"X":432.0,"Y":168.0}]},{"StartTime":195782.0,"Objects":[{"StartTime":195782.0,"EndTime":195782.0,"X":424.0,"Y":144.0}]},{"StartTime":195893.0,"Objects":[{"StartTime":195893.0,"EndTime":195893.0,"X":416.0,"Y":120.0}]},{"StartTime":196004.0,"Objects":[{"StartTime":196004.0,"EndTime":196004.0,"X":400.0,"Y":96.0}]},{"StartTime":196115.0,"Objects":[{"StartTime":196115.0,"EndTime":196115.0,"X":384.0,"Y":80.0}]},{"StartTime":196226.0,"Objects":[{"StartTime":196226.0,"EndTime":196226.0,"X":368.0,"Y":64.0}]},{"StartTime":196337.0,"Objects":[{"StartTime":196337.0,"EndTime":196337.0,"X":344.0,"Y":48.0}]},{"StartTime":196449.0,"Objects":[{"StartTime":196449.0,"EndTime":196449.0,"X":320.0,"Y":40.0}]},{"StartTime":196560.0,"Objects":[{"StartTime":196560.0,"EndTime":196560.0,"X":296.0,"Y":40.0}]},{"StartTime":196671.0,"Objects":[{"StartTime":196671.0,"EndTime":196671.0,"X":272.0,"Y":40.0}]},{"StartTime":196782.0,"Objects":[{"StartTime":196782.0,"EndTime":196782.0,"X":248.0,"Y":48.0}]},{"StartTime":196893.0,"Objects":[{"StartTime":196893.0,"EndTime":196893.0,"X":224.0,"Y":64.0}]},{"StartTime":197004.0,"Objects":[{"StartTime":197004.0,"EndTime":197004.0,"X":208.0,"Y":80.0}]},{"StartTime":197115.0,"Objects":[{"StartTime":197115.0,"EndTime":197115.0,"X":192.0,"Y":104.0}]},{"StartTime":197226.0,"Objects":[{"StartTime":197226.0,"EndTime":197226.0,"X":184.0,"Y":128.0}]},{"StartTime":197337.0,"Objects":[{"StartTime":197337.0,"EndTime":197337.0,"X":176.0,"Y":152.0}]},{"StartTime":197449.0,"Objects":[{"StartTime":197449.0,"EndTime":197449.0,"X":160.0,"Y":168.0}]},{"StartTime":197560.0,"Objects":[{"StartTime":197560.0,"EndTime":197560.0,"X":136.0,"Y":184.0}]},{"StartTime":197671.0,"Objects":[{"StartTime":197671.0,"EndTime":197671.0,"X":112.0,"Y":192.0}]},{"StartTime":197782.0,"Objects":[{"StartTime":197782.0,"EndTime":197782.0,"X":96.0,"Y":208.0}]},{"StartTime":197893.0,"Objects":[{"StartTime":197893.0,"EndTime":197893.0,"X":80.0,"Y":232.0}]},{"StartTime":198004.0,"Objects":[{"StartTime":198004.0,"EndTime":198004.0,"X":80.0,"Y":256.0}]},{"StartTime":198115.0,"Objects":[{"StartTime":198115.0,"EndTime":198115.0,"X":88.0,"Y":280.0}]},{"StartTime":198226.0,"Objects":[{"StartTime":198226.0,"EndTime":198226.0,"X":112.0,"Y":296.0}]},{"StartTime":198337.0,"Objects":[{"StartTime":198337.0,"EndTime":198337.0,"X":136.0,"Y":304.0}]},{"StartTime":198449.0,"Objects":[{"StartTime":198449.0,"EndTime":198449.0,"X":160.0,"Y":304.0}]},{"StartTime":198560.0,"Objects":[{"StartTime":198560.0,"EndTime":198560.0,"X":184.0,"Y":296.0}]},{"StartTime":198671.0,"Objects":[{"StartTime":198671.0,"EndTime":198671.0,"X":200.0,"Y":280.0}]},{"StartTime":198782.0,"Objects":[{"StartTime":198782.0,"EndTime":198782.0,"X":224.0,"Y":264.0}]},{"StartTime":198893.0,"Objects":[{"StartTime":198893.0,"EndTime":198893.0,"X":248.0,"Y":256.0}]},{"StartTime":199004.0,"Objects":[{"StartTime":199004.0,"EndTime":199004.0,"X":272.0,"Y":264.0}]},{"StartTime":199115.0,"Objects":[{"StartTime":199115.0,"EndTime":199115.0,"X":296.0,"Y":280.0}]},{"StartTime":199226.0,"Objects":[{"StartTime":199226.0,"EndTime":199226.0,"X":320.0,"Y":288.0}]},{"StartTime":199337.0,"Objects":[{"StartTime":199337.0,"EndTime":199337.0,"X":344.0,"Y":288.0}]},{"StartTime":199449.0,"Objects":[{"StartTime":199449.0,"EndTime":199449.0,"X":368.0,"Y":280.0}]},{"StartTime":199560.0,"Objects":[{"StartTime":199560.0,"EndTime":199560.0,"X":392.0,"Y":264.0}]},{"StartTime":199671.0,"Objects":[{"StartTime":199671.0,"EndTime":199671.0,"X":408.0,"Y":248.0}]},{"StartTime":199782.0,"Objects":[{"StartTime":199782.0,"EndTime":199782.0,"X":424.0,"Y":224.0}]},{"StartTime":199893.0,"Objects":[{"StartTime":199893.0,"EndTime":199893.0,"X":432.0,"Y":200.0}]},{"StartTime":200004.0,"Objects":[{"StartTime":200004.0,"EndTime":200004.0,"X":432.0,"Y":176.0}]},{"StartTime":200115.0,"Objects":[{"StartTime":200115.0,"EndTime":200115.0,"X":424.0,"Y":152.0}]},{"StartTime":200226.0,"Objects":[{"StartTime":200226.0,"EndTime":200226.0,"X":416.0,"Y":128.0}]},{"StartTime":200337.0,"Objects":[{"StartTime":200337.0,"EndTime":200337.0,"X":400.0,"Y":104.0}]},{"StartTime":200449.0,"Objects":[{"StartTime":200449.0,"EndTime":200449.0,"X":384.0,"Y":88.0}]},{"StartTime":200560.0,"Objects":[{"StartTime":200560.0,"EndTime":200560.0,"X":360.0,"Y":72.0}]},{"StartTime":200671.0,"Objects":[{"StartTime":200671.0,"EndTime":200671.0,"X":336.0,"Y":64.0}]},{"StartTime":200782.0,"Objects":[{"StartTime":200782.0,"EndTime":200782.0,"X":312.0,"Y":64.0}]},{"StartTime":200893.0,"Objects":[{"StartTime":200893.0,"EndTime":200893.0,"X":288.0,"Y":72.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":200920.0,"EndTime":200920.0,"X":283.0971,"Y":47.48548,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201004.0,"Objects":[{"StartTime":201004.0,"EndTime":201004.0,"X":264.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201031.0,"EndTime":201031.0,"X":259.0971,"Y":55.48548,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201115.0,"Objects":[{"StartTime":201115.0,"EndTime":201115.0,"X":240.0,"Y":88.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201142.0,"EndTime":201142.0,"X":235.097092,"Y":63.48548,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201226.0,"Objects":[{"StartTime":201226.0,"EndTime":201226.0,"X":216.0,"Y":96.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201253.0,"EndTime":201253.0,"X":211.097092,"Y":71.48548,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201337.0,"Objects":[{"StartTime":201337.0,"EndTime":201337.0,"X":192.0,"Y":104.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201364.0,"EndTime":201364.0,"X":187.097092,"Y":79.48548,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201449.0,"Objects":[{"StartTime":201449.0,"EndTime":201449.0,"X":168.0,"Y":112.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201476.0,"EndTime":201476.0,"X":163.097092,"Y":87.48548,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201560.0,"Objects":[{"StartTime":201560.0,"EndTime":201560.0,"X":144.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201587.0,"EndTime":201587.0,"X":139.097092,"Y":95.48548,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201671.0,"Objects":[{"StartTime":201671.0,"EndTime":201671.0,"X":120.0,"Y":128.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201698.0,"EndTime":201698.0,"X":115.0971,"Y":103.485489,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201782.0,"Objects":[{"StartTime":201782.0,"EndTime":201782.0,"X":96.0,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201809.0,"EndTime":201809.0,"X":84.33103,"Y":113.890381,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":201893.0,"Objects":[{"StartTime":201893.0,"EndTime":201893.0,"X":57.0,"Y":173.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":201920.0,"EndTime":201920.0,"X":36.010746,"Y":159.418716,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":202004.0,"Objects":[{"StartTime":202004.0,"EndTime":202004.0,"X":41.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":202031.0,"EndTime":202031.0,"X":16.03119,"Y":222.751556,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":202115.0,"Objects":[{"StartTime":202115.0,"EndTime":202115.0,"X":53.0,"Y":277.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":202142.0,"EndTime":202142.0,"X":30.6393185,"Y":288.180328,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":202226.0,"Objects":[{"StartTime":202226.0,"EndTime":202226.0,"X":90.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":202253.0,"EndTime":202253.0,"X":76.13249,"Y":336.80127,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":202337.0,"Objects":[{"StartTime":202337.0,"EndTime":202337.0,"X":141.0,"Y":332.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":202364.0,"EndTime":202364.0,"X":139.719635,"Y":356.9672,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":202449.0,"Objects":[{"StartTime":202449.0,"EndTime":202449.0,"X":194.0,"Y":320.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":202476.0,"EndTime":202476.0,"X":205.4337,"Y":342.2322,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":202560.0,"Objects":[{"StartTime":202560.0,"EndTime":202560.0,"X":233.0,"Y":283.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":202587.0,"EndTime":202587.0,"X":254.091537,"Y":296.421875,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":202671.0,"Objects":[{"StartTime":202671.0,"EndTime":202671.0,"X":249.0,"Y":231.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":202857.0,"EndTime":202857.0,"X":398.498627,"Y":218.746017,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":203115.0,"Objects":[{"StartTime":203115.0,"EndTime":203115.0,"X":496.0,"Y":104.0}]},{"StartTime":203337.0,"Objects":[{"StartTime":203337.0,"EndTime":203337.0,"X":400.0,"Y":328.0}]},{"StartTime":203449.0,"Objects":[{"StartTime":203449.0,"EndTime":203449.0,"X":400.0,"Y":328.0}]},{"StartTime":203560.0,"Objects":[{"StartTime":203560.0,"EndTime":203560.0,"X":400.0,"Y":328.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":203746.0,"EndTime":203746.0,"X":336.4116,"Y":258.334137,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":203893.0,"Objects":[{"StartTime":203893.0,"EndTime":203893.0,"X":296.0,"Y":104.0}]},{"StartTime":204004.0,"Objects":[{"StartTime":204004.0,"EndTime":204004.0,"X":296.0,"Y":104.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":204079.0,"EndTime":204079.0,"X":345.497467,"Y":111.071068,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":204226.0,"Objects":[{"StartTime":204226.0,"EndTime":204226.0,"X":160.0,"Y":168.0}]},{"StartTime":204337.0,"Objects":[{"StartTime":204337.0,"EndTime":204337.0,"X":160.0,"Y":168.0}]},{"StartTime":204449.0,"Objects":[{"StartTime":204449.0,"EndTime":204449.0,"X":160.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":204635.0,"EndTime":204635.0,"X":66.598465,"Y":180.720764,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":204893.0,"Objects":[{"StartTime":204893.0,"EndTime":204893.0,"X":136.0,"Y":320.0}]},{"StartTime":205115.0,"Objects":[{"StartTime":205115.0,"EndTime":205115.0,"X":304.0,"Y":224.0}]},{"StartTime":205226.0,"Objects":[{"StartTime":205226.0,"EndTime":205226.0,"X":328.0,"Y":224.0}]},{"StartTime":205337.0,"Objects":[{"StartTime":205337.0,"EndTime":205337.0,"X":352.0,"Y":224.0}]},{"StartTime":205560.0,"Objects":[{"StartTime":205560.0,"EndTime":205560.0,"X":464.0,"Y":144.0}]},{"StartTime":205671.0,"Objects":[{"StartTime":205671.0,"EndTime":205671.0,"X":464.0,"Y":144.0}]},{"StartTime":205782.0,"Objects":[{"StartTime":205782.0,"EndTime":205782.0,"X":464.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":205857.0,"EndTime":205857.0,"X":480.1552,"Y":98.39854,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":206004.0,"Objects":[{"StartTime":206004.0,"EndTime":206004.0,"X":400.0,"Y":320.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":206079.0,"EndTime":206079.0,"X":383.8448,"Y":365.601471,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":206226.0,"Objects":[{"StartTime":206226.0,"EndTime":206226.0,"X":304.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":206412.0,"EndTime":206412.0,"X":211.075516,"Y":206.428955,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":206671.0,"Objects":[{"StartTime":206671.0,"EndTime":206671.0,"X":24.0,"Y":296.0}]},{"StartTime":206893.0,"Objects":[{"StartTime":206893.0,"EndTime":206893.0,"X":160.0,"Y":104.0}]},{"StartTime":207115.0,"Objects":[{"StartTime":207115.0,"EndTime":207115.0,"X":248.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":207301.0,"EndTime":207301.0,"X":340.9245,"Y":321.571045,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":207782.0,"Objects":[{"StartTime":207782.0,"EndTime":207782.0,"X":340.0,"Y":321.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":207968.0,"EndTime":207968.0,"X":321.571136,"Y":222.712769,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":208226.0,"Objects":[{"StartTime":208226.0,"EndTime":208226.0,"X":376.0,"Y":72.0}]},{"StartTime":208449.0,"Objects":[{"StartTime":208449.0,"EndTime":208449.0,"X":144.0,"Y":152.0}]},{"StartTime":208671.0,"Objects":[{"StartTime":208671.0,"EndTime":208671.0,"X":256.0,"Y":336.0}]},{"StartTime":208893.0,"Objects":[{"StartTime":208893.0,"EndTime":208893.0,"X":272.0,"Y":40.0}]},{"StartTime":209115.0,"Objects":[{"StartTime":209115.0,"EndTime":209115.0,"X":112.0,"Y":304.0}]},{"StartTime":209337.0,"Objects":[{"StartTime":209337.0,"EndTime":209337.0,"X":440.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":209412.0,"EndTime":209412.0,"X":489.739655,"Y":225.6747,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":209560.0,"Objects":[{"StartTime":209560.0,"EndTime":209560.0,"X":440.0,"Y":320.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":209635.0,"EndTime":209635.0,"X":390.260345,"Y":318.325317,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":209782.0,"Objects":[{"StartTime":209782.0,"EndTime":209782.0,"X":248.0,"Y":232.0}]},{"StartTime":209893.0,"Objects":[{"StartTime":209893.0,"EndTime":209893.0,"X":216.0,"Y":216.0}]},{"StartTime":210004.0,"Objects":[{"StartTime":210004.0,"EndTime":210004.0,"X":184.0,"Y":208.0}]},{"StartTime":210115.0,"Objects":[{"StartTime":210115.0,"EndTime":210115.0,"X":152.0,"Y":208.0}]},{"StartTime":210226.0,"Objects":[{"StartTime":210226.0,"EndTime":210226.0,"X":121.0,"Y":223.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":210301.0,"EndTime":210301.0,"X":114.463181,"Y":173.429138,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":210449.0,"Objects":[{"StartTime":210449.0,"EndTime":210449.0,"X":53.0,"Y":84.0}]},{"StartTime":210560.0,"Objects":[{"StartTime":210560.0,"EndTime":210560.0,"X":53.0,"Y":84.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":210746.0,"EndTime":210746.0,"X":149.715561,"Y":70.03106,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":210893.0,"Objects":[{"StartTime":210893.0,"EndTime":210893.0,"X":339.0,"Y":150.0}]},{"StartTime":211004.0,"Objects":[{"StartTime":211004.0,"EndTime":211004.0,"X":339.0,"Y":150.0}]},{"StartTime":211115.0,"Objects":[{"StartTime":211115.0,"EndTime":211115.0,"X":339.0,"Y":150.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":211301.0,"EndTime":211301.0,"X":239.215088,"Y":143.4448,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":211449.0,"Objects":[{"StartTime":211449.0,"EndTime":211449.0,"X":406.0,"Y":107.0}]},{"StartTime":211560.0,"Objects":[{"StartTime":211560.0,"EndTime":211560.0,"X":406.0,"Y":107.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":211857.0,"EndTime":211857.0,"X":400.23468,"Y":244.319275,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":212004.0,"Objects":[{"StartTime":212004.0,"EndTime":212004.0,"X":461.0,"Y":315.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":212079.0,"EndTime":212079.0,"X":510.76535,"Y":310.1617,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":212226.0,"Objects":[{"StartTime":212226.0,"EndTime":212226.0,"X":308.0,"Y":351.0}]},{"StartTime":212337.0,"Objects":[{"StartTime":212337.0,"EndTime":212337.0,"X":308.0,"Y":351.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":212523.0,"EndTime":212523.0,"X":212.197952,"Y":335.321045,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":212671.0,"Objects":[{"StartTime":212671.0,"EndTime":212671.0,"X":64.0,"Y":352.0}]},{"StartTime":212782.0,"Objects":[{"StartTime":212782.0,"EndTime":212782.0,"X":64.0,"Y":352.0}]},{"StartTime":212893.0,"Objects":[{"StartTime":212893.0,"EndTime":212893.0,"X":64.0,"Y":352.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":213079.0,"EndTime":213079.0,"X":91.47211,"Y":255.8476,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":213226.0,"Objects":[{"StartTime":213226.0,"EndTime":213226.0,"X":56.0,"Y":96.0}]},{"StartTime":213337.0,"Objects":[{"StartTime":213337.0,"EndTime":213337.0,"X":56.0,"Y":96.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":213634.0,"EndTime":213634.0,"X":185.197464,"Y":125.872383,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":213782.0,"Objects":[{"StartTime":213782.0,"EndTime":213782.0,"X":232.0,"Y":104.0}]},{"StartTime":213893.0,"Objects":[{"StartTime":213893.0,"EndTime":213893.0,"X":280.0,"Y":80.0}]},{"StartTime":214004.0,"Objects":[{"StartTime":214004.0,"EndTime":214004.0,"X":328.0,"Y":72.0}]},{"StartTime":214115.0,"Objects":[{"StartTime":214115.0,"EndTime":214115.0,"X":376.0,"Y":80.0}]},{"StartTime":214226.0,"Objects":[{"StartTime":214226.0,"EndTime":214226.0,"X":416.0,"Y":104.0}]},{"StartTime":214337.0,"Objects":[{"StartTime":214337.0,"EndTime":214337.0,"X":448.0,"Y":144.0}]},{"StartTime":214449.0,"Objects":[{"StartTime":214449.0,"EndTime":214449.0,"X":456.0,"Y":192.0}]},{"StartTime":214560.0,"Objects":[{"StartTime":214560.0,"EndTime":214560.0,"X":448.0,"Y":240.0}]},{"StartTime":214671.0,"Objects":[{"StartTime":214671.0,"EndTime":214671.0,"X":416.0,"Y":280.0}]},{"StartTime":214782.0,"Objects":[{"StartTime":214782.0,"EndTime":214782.0,"X":376.0,"Y":304.0}]},{"StartTime":214893.0,"Objects":[{"StartTime":214893.0,"EndTime":214893.0,"X":328.0,"Y":312.0}]},{"StartTime":215004.0,"Objects":[{"StartTime":215004.0,"EndTime":215004.0,"X":280.0,"Y":304.0}]},{"StartTime":215115.0,"Objects":[{"StartTime":215115.0,"EndTime":215115.0,"X":240.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":215142.0,"EndTime":215142.0,"X":215.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":215226.0,"Objects":[{"StartTime":215226.0,"EndTime":215226.0,"X":215.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":215253.0,"EndTime":215253.0,"X":190.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":215337.0,"Objects":[{"StartTime":215337.0,"EndTime":215337.0,"X":190.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":215364.0,"EndTime":215364.0,"X":165.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":215449.0,"Objects":[{"StartTime":215449.0,"EndTime":215449.0,"X":165.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":215476.0,"EndTime":215476.0,"X":140.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":215560.0,"Objects":[{"StartTime":215560.0,"EndTime":215560.0,"X":112.0,"Y":280.0}]},{"StartTime":215671.0,"Objects":[{"StartTime":215671.0,"EndTime":215671.0,"X":32.0,"Y":224.0}]},{"StartTime":215782.0,"Objects":[{"StartTime":215782.0,"EndTime":215782.0,"X":120.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":215837.0,"EndTime":215837.0,"X":120.0,"Y":151.22522,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":215857.0,"EndTime":215857.0,"X":120.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":216115.0,"Objects":[{"StartTime":216115.0,"EndTime":216115.0,"X":240.0,"Y":64.0}]},{"StartTime":216337.0,"Objects":[{"StartTime":216337.0,"EndTime":216337.0,"X":344.0,"Y":224.0}]},{"StartTime":216560.0,"Objects":[{"StartTime":216560.0,"EndTime":216560.0,"X":448.0,"Y":32.0}]},{"StartTime":216893.0,"Objects":[{"StartTime":216893.0,"EndTime":216893.0,"X":145.0,"Y":84.0}]},{"StartTime":217115.0,"Objects":[{"StartTime":217115.0,"EndTime":217115.0,"X":198.0,"Y":332.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":217301.0,"EndTime":217301.0,"X":215.926071,"Y":213.346481,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":217449.0,"Objects":[{"StartTime":217449.0,"EndTime":217449.0,"X":308.0,"Y":165.0}]},{"StartTime":217560.0,"Objects":[{"StartTime":217560.0,"EndTime":217560.0,"X":308.0,"Y":165.0}]},{"StartTime":217782.0,"Objects":[{"StartTime":217782.0,"EndTime":217782.0,"X":81.0,"Y":244.0}]},{"StartTime":217893.0,"Objects":[{"StartTime":217893.0,"EndTime":217893.0,"X":76.0,"Y":214.0}]},{"StartTime":218004.0,"Objects":[{"StartTime":218004.0,"EndTime":218004.0,"X":72.0,"Y":184.0}]},{"StartTime":218115.0,"Objects":[{"StartTime":218115.0,"EndTime":218115.0,"X":67.0,"Y":155.0}]},{"StartTime":218226.0,"Objects":[{"StartTime":218226.0,"EndTime":218226.0,"X":63.0,"Y":125.0}]},{"StartTime":218449.0,"Objects":[{"StartTime":218449.0,"EndTime":218449.0,"X":200.0,"Y":49.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":218746.0,"EndTime":218746.0,"X":368.463257,"Y":79.80888,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":218893.0,"Objects":[{"StartTime":218893.0,"EndTime":218893.0,"X":422.0,"Y":36.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":219190.0,"EndTime":219190.0,"X":352.063354,"Y":185.676758,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":219337.0,"Objects":[{"StartTime":219337.0,"EndTime":219337.0,"X":512.0,"Y":151.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":219412.0,"EndTime":219412.0,"X":452.663818,"Y":142.099579,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":219560.0,"Objects":[{"StartTime":219560.0,"EndTime":219560.0,"X":318.0,"Y":194.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":219615.0,"EndTime":219615.0,"X":288.526764,"Y":198.287018,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":219671.0,"EndTime":219671.0,"X":317.892822,"Y":194.0156,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":219726.0,"EndTime":219726.0,"X":288.4196,"Y":198.3026,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":219782.0,"EndTime":219782.0,"X":317.785645,"Y":194.031174,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":219801.0,"EndTime":219801.0,"X":288.3124,"Y":198.318192,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":219893.0,"Objects":[{"StartTime":219893.0,"EndTime":219893.0,"X":424.0,"Y":218.0}]},{"StartTime":220004.0,"Objects":[{"StartTime":220004.0,"EndTime":220004.0,"X":426.0,"Y":247.0}]},{"StartTime":220115.0,"Objects":[{"StartTime":220115.0,"EndTime":220115.0,"X":429.0,"Y":276.0}]},{"StartTime":220226.0,"Objects":[{"StartTime":220226.0,"EndTime":220226.0,"X":432.0,"Y":305.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":220634.0,"EndTime":220634.0,"X":203.804153,"Y":317.883484,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":220893.0,"Objects":[{"StartTime":220893.0,"EndTime":220893.0,"X":97.0,"Y":166.0}]},{"StartTime":221115.0,"Objects":[{"StartTime":221115.0,"EndTime":221115.0,"X":269.0,"Y":215.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":221190.0,"EndTime":221190.0,"X":275.707672,"Y":155.376129,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":221337.0,"Objects":[{"StartTime":221337.0,"EndTime":221337.0,"X":159.0,"Y":41.0}]},{"StartTime":221449.0,"Objects":[{"StartTime":221449.0,"EndTime":221449.0,"X":163.0,"Y":78.0}]},{"StartTime":221560.0,"Objects":[{"StartTime":221560.0,"EndTime":221560.0,"X":167.0,"Y":115.0}]},{"StartTime":221671.0,"Objects":[{"StartTime":221671.0,"EndTime":221671.0,"X":171.0,"Y":152.0}]},{"StartTime":221782.0,"Objects":[{"StartTime":221782.0,"EndTime":221782.0,"X":176.0,"Y":189.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":221857.0,"EndTime":221857.0,"X":169.374115,"Y":248.633026,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":222004.0,"Objects":[{"StartTime":222004.0,"EndTime":222004.0,"X":24.0,"Y":352.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":222301.0,"EndTime":222301.0,"X":203.19368,"Y":334.9816,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":222449.0,"Objects":[{"StartTime":222449.0,"EndTime":222449.0,"X":346.0,"Y":297.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":222746.0,"EndTime":222746.0,"X":166.79744,"Y":280.075317,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":222893.0,"Objects":[{"StartTime":222893.0,"EndTime":222893.0,"X":68.0,"Y":237.0}]},{"StartTime":223004.0,"Objects":[{"StartTime":223004.0,"EndTime":223004.0,"X":103.0,"Y":233.0}]},{"StartTime":223115.0,"Objects":[{"StartTime":223115.0,"EndTime":223115.0,"X":139.0,"Y":230.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":223190.0,"EndTime":223190.0,"X":198.678772,"Y":236.2004,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":223337.0,"Objects":[{"StartTime":223337.0,"EndTime":223337.0,"X":377.0,"Y":324.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":223412.0,"EndTime":223412.0,"X":409.919373,"Y":273.837128,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":223560.0,"Objects":[{"StartTime":223560.0,"EndTime":223560.0,"X":278.0,"Y":145.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":223635.0,"EndTime":223635.0,"X":286.687958,"Y":204.367661,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":223782.0,"Objects":[{"StartTime":223782.0,"EndTime":223782.0,"X":452.0,"Y":83.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":223857.0,"EndTime":223857.0,"X":392.321228,"Y":89.20039,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":224004.0,"Objects":[{"StartTime":224004.0,"EndTime":224004.0,"X":186.0,"Y":134.0}]},{"StartTime":224115.0,"Objects":[{"StartTime":224115.0,"EndTime":224115.0,"X":146.0,"Y":131.0}]},{"StartTime":224226.0,"Objects":[{"StartTime":224226.0,"EndTime":224226.0,"X":109.0,"Y":116.0}]},{"StartTime":224337.0,"Objects":[{"StartTime":224337.0,"EndTime":224337.0,"X":77.0,"Y":92.0}]},{"StartTime":224449.0,"Objects":[{"StartTime":224449.0,"EndTime":224449.0,"X":15.0,"Y":29.0}]},{"StartTime":224560.0,"Objects":[{"StartTime":224560.0,"EndTime":224560.0,"X":11.0,"Y":78.0}]},{"StartTime":224671.0,"Objects":[{"StartTime":224671.0,"EndTime":224671.0,"X":8.0,"Y":128.0}]},{"StartTime":224782.0,"Objects":[{"StartTime":224782.0,"EndTime":224782.0,"X":4.0,"Y":178.0}]},{"StartTime":224893.0,"Objects":[{"StartTime":224893.0,"EndTime":224893.0,"X":89.0,"Y":198.0}]},{"StartTime":225004.0,"Objects":[{"StartTime":225004.0,"EndTime":225004.0,"X":96.0,"Y":237.0}]},{"StartTime":225115.0,"Objects":[{"StartTime":225115.0,"EndTime":225115.0,"X":103.0,"Y":276.0}]},{"StartTime":225226.0,"Objects":[{"StartTime":225226.0,"EndTime":225226.0,"X":111.0,"Y":315.0}]},{"StartTime":225337.0,"Objects":[{"StartTime":225337.0,"EndTime":225337.0,"X":206.0,"Y":281.0}]},{"StartTime":225449.0,"Objects":[{"StartTime":225449.0,"EndTime":225449.0,"X":245.0,"Y":287.0}]},{"StartTime":225560.0,"Objects":[{"StartTime":225560.0,"EndTime":225560.0,"X":285.0,"Y":293.0}]},{"StartTime":225671.0,"Objects":[{"StartTime":225671.0,"EndTime":225671.0,"X":324.0,"Y":299.0}]},{"StartTime":225782.0,"Objects":[{"StartTime":225782.0,"EndTime":225782.0,"X":408.0,"Y":249.0}]},{"StartTime":225893.0,"Objects":[{"StartTime":225893.0,"EndTime":225893.0,"X":373.0,"Y":213.0}]},{"StartTime":226004.0,"Objects":[{"StartTime":226004.0,"EndTime":226004.0,"X":326.0,"Y":200.0}]},{"StartTime":226115.0,"Objects":[{"StartTime":226115.0,"EndTime":226115.0,"X":278.0,"Y":213.0}]},{"StartTime":226226.0,"Objects":[{"StartTime":226226.0,"EndTime":226226.0,"X":206.0,"Y":281.0}]},{"StartTime":226337.0,"Objects":[{"StartTime":226337.0,"EndTime":226337.0,"X":182.0,"Y":237.0}]},{"StartTime":226449.0,"Objects":[{"StartTime":226449.0,"EndTime":226449.0,"X":182.0,"Y":188.0}]},{"StartTime":226560.0,"Objects":[{"StartTime":226560.0,"EndTime":226560.0,"X":207.0,"Y":144.0}]},{"StartTime":226671.0,"Objects":[{"StartTime":226671.0,"EndTime":226671.0,"X":306.0,"Y":164.0}]},{"StartTime":226782.0,"Objects":[{"StartTime":226782.0,"EndTime":226782.0,"X":354.0,"Y":156.0}]},{"StartTime":226893.0,"Objects":[{"StartTime":226893.0,"EndTime":226893.0,"X":392.0,"Y":124.0}]},{"StartTime":227004.0,"Objects":[{"StartTime":227004.0,"EndTime":227004.0,"X":407.0,"Y":77.0}]},{"StartTime":227115.0,"Objects":[{"StartTime":227115.0,"EndTime":227115.0,"X":301.0,"Y":55.0}]},{"StartTime":227226.0,"Objects":[{"StartTime":227226.0,"EndTime":227226.0,"X":303.0,"Y":109.0}]},{"StartTime":227337.0,"Objects":[{"StartTime":227337.0,"EndTime":227337.0,"X":306.0,"Y":164.0}]},{"StartTime":227449.0,"Objects":[{"StartTime":227449.0,"EndTime":227449.0,"X":308.0,"Y":219.0}]},{"StartTime":227560.0,"Objects":[{"StartTime":227560.0,"EndTime":227560.0,"X":360.0,"Y":304.0}]},{"StartTime":227671.0,"Objects":[{"StartTime":227671.0,"EndTime":227671.0,"X":301.0,"Y":297.0}]},{"StartTime":227782.0,"Objects":[{"StartTime":227782.0,"EndTime":227782.0,"X":246.0,"Y":321.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":227857.0,"EndTime":227857.0,"X":187.82814,"Y":324.502777,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":228004.0,"Objects":[{"StartTime":228004.0,"EndTime":228004.0,"X":49.0,"Y":207.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":228079.0,"EndTime":228079.0,"X":54.65045,"Y":266.733337,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":228226.0,"Objects":[{"StartTime":228226.0,"EndTime":228226.0,"X":208.0,"Y":185.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":228301.0,"EndTime":228301.0,"X":213.650452,"Y":125.266663,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":228449.0,"Objects":[{"StartTime":228449.0,"EndTime":228449.0,"X":44.0,"Y":29.0}]},{"StartTime":228560.0,"Objects":[{"StartTime":228560.0,"EndTime":228560.0,"X":40.0,"Y":58.0}]},{"StartTime":228671.0,"Objects":[{"StartTime":228671.0,"EndTime":228671.0,"X":36.0,"Y":88.0}]},{"StartTime":228782.0,"Objects":[{"StartTime":228782.0,"EndTime":228782.0,"X":32.0,"Y":117.0}]},{"StartTime":228893.0,"Objects":[{"StartTime":228893.0,"EndTime":228893.0,"X":123.0,"Y":105.0}]},{"StartTime":229004.0,"Objects":[{"StartTime":229004.0,"EndTime":229004.0,"X":212.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":229031.0,"EndTime":229031.0,"X":239.085815,"Y":79.10199,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":229115.0,"Objects":[{"StartTime":229115.0,"EndTime":229115.0,"X":260.0,"Y":125.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":229142.0,"EndTime":229142.0,"X":289.9626,"Y":123.501869,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":229226.0,"Objects":[{"StartTime":229226.0,"EndTime":229226.0,"X":282.0,"Y":180.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":229253.0,"EndTime":229253.0,"X":310.3038,"Y":189.94458,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":229337.0,"Objects":[{"StartTime":229337.0,"EndTime":229337.0,"X":269.0,"Y":237.0}]},{"StartTime":229449.0,"Objects":[{"StartTime":229449.0,"EndTime":229449.0,"X":219.0,"Y":237.0}]},{"StartTime":229560.0,"Objects":[{"StartTime":229560.0,"EndTime":229560.0,"X":174.0,"Y":257.0}]},{"StartTime":229671.0,"Objects":[{"StartTime":229671.0,"EndTime":229671.0,"X":142.0,"Y":295.0}]},{"StartTime":229782.0,"Objects":[{"StartTime":229782.0,"EndTime":229782.0,"X":245.0,"Y":353.0}]},{"StartTime":229893.0,"Objects":[{"StartTime":229893.0,"EndTime":229893.0,"X":293.0,"Y":340.0}]},{"StartTime":230004.0,"Objects":[{"StartTime":230004.0,"EndTime":230004.0,"X":342.0,"Y":349.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":230031.0,"EndTime":230031.0,"X":369.574341,"Y":337.182434,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":230115.0,"Objects":[{"StartTime":230115.0,"EndTime":230115.0,"X":383.0,"Y":376.0}]},{"StartTime":230226.0,"Objects":[{"StartTime":230226.0,"EndTime":230226.0,"X":467.0,"Y":284.0}]},{"StartTime":230337.0,"Objects":[{"StartTime":230337.0,"EndTime":230337.0,"X":464.0,"Y":234.0}]},{"StartTime":230449.0,"Objects":[{"StartTime":230449.0,"EndTime":230449.0,"X":461.0,"Y":184.0}]},{"StartTime":230560.0,"Objects":[{"StartTime":230560.0,"EndTime":230560.0,"X":458.0,"Y":134.0}]},{"StartTime":230671.0,"Objects":[{"StartTime":230671.0,"EndTime":230671.0,"X":310.0,"Y":42.0}]},{"StartTime":230782.0,"Objects":[{"StartTime":230782.0,"EndTime":230782.0,"X":300.0,"Y":90.0}]},{"StartTime":230893.0,"Objects":[{"StartTime":230893.0,"EndTime":230893.0,"X":322.0,"Y":133.0}]},{"StartTime":231004.0,"Objects":[{"StartTime":231004.0,"EndTime":231004.0,"X":367.0,"Y":154.0}]},{"StartTime":231115.0,"Objects":[{"StartTime":231115.0,"EndTime":231115.0,"X":458.0,"Y":134.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":231412.0,"EndTime":231412.0,"X":463.8211,"Y":223.811554,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":231782.0,"Objects":[{"StartTime":231782.0,"EndTime":231782.0,"X":369.0,"Y":307.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":232190.0,"EndTime":232190.0,"X":256.358734,"Y":326.1779,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":232449.0,"Objects":[{"StartTime":232449.0,"EndTime":232449.0,"X":137.0,"Y":289.0}]},{"StartTime":232671.0,"Objects":[{"StartTime":232671.0,"EndTime":232671.0,"X":137.0,"Y":289.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":233079.0,"EndTime":233079.0,"X":137.642288,"Y":172.083252,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":233337.0,"Objects":[{"StartTime":233337.0,"EndTime":233337.0,"X":159.0,"Y":135.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":233745.0,"EndTime":233745.0,"X":273.471161,"Y":155.800812,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":234004.0,"Objects":[{"StartTime":234004.0,"EndTime":234004.0,"X":375.0,"Y":81.0}]},{"StartTime":234226.0,"Objects":[{"StartTime":234226.0,"EndTime":234226.0,"X":389.0,"Y":206.0}]},{"StartTime":234449.0,"Objects":[{"StartTime":234449.0,"EndTime":234449.0,"X":273.0,"Y":155.0}]},{"StartTime":235115.0,"Objects":[{"StartTime":235115.0,"EndTime":235115.0,"X":143.0,"Y":320.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":235559.0,"EndTime":235559.0,"X":31.683857,"Y":336.082733,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":235967.0,"EndTime":235967.0,"X":143.0,"Y":320.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":236226.0,"Objects":[{"StartTime":236226.0,"EndTime":236226.0,"X":253.0,"Y":363.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":236634.0,"EndTime":236634.0,"X":258.2222,"Y":244.874435,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":236893.0,"Objects":[{"StartTime":236893.0,"EndTime":236893.0,"X":303.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":237301.0,"EndTime":237301.0,"X":183.374,"Y":198.533188,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":237560.0,"Objects":[{"StartTime":237560.0,"EndTime":237560.0,"X":76.0,"Y":264.0}]},{"StartTime":237782.0,"Objects":[{"StartTime":237782.0,"EndTime":237782.0,"X":48.0,"Y":170.0}]},{"StartTime":238004.0,"Objects":[{"StartTime":238004.0,"EndTime":238004.0,"X":99.0,"Y":88.0}]},{"StartTime":238226.0,"Objects":[{"StartTime":238226.0,"EndTime":238226.0,"X":195.0,"Y":72.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":238523.0,"EndTime":238523.0,"X":282.5216,"Y":88.84021,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":238893.0,"Objects":[{"StartTime":238893.0,"EndTime":238893.0,"X":430.0,"Y":40.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":239301.0,"EndTime":239301.0,"X":448.539764,"Y":158.521881,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":239560.0,"Objects":[{"StartTime":239560.0,"EndTime":239560.0,"X":443.0,"Y":280.0}]},{"StartTime":239782.0,"Objects":[{"StartTime":239782.0,"EndTime":239782.0,"X":340.0,"Y":214.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":240190.0,"EndTime":240190.0,"X":341.869324,"Y":99.87049,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":240449.0,"Objects":[{"StartTime":240449.0,"EndTime":240449.0,"X":430.0,"Y":40.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":240857.0,"EndTime":240857.0,"X":311.672272,"Y":37.1086044,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":241115.0,"Objects":[{"StartTime":241115.0,"EndTime":241115.0,"X":195.0,"Y":72.0}]},{"StartTime":241337.0,"Objects":[{"StartTime":241337.0,"EndTime":241337.0,"X":181.0,"Y":191.0}]},{"StartTime":241560.0,"Objects":[{"StartTime":241560.0,"EndTime":241560.0,"X":291.0,"Y":143.0}]},{"StartTime":241782.0,"Objects":[{"StartTime":241782.0,"EndTime":241782.0,"X":195.0,"Y":72.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":242079.0,"EndTime":242079.0,"X":110.66143,"Y":93.17287,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":242449.0,"Objects":[{"StartTime":242449.0,"EndTime":242449.0,"X":90.0,"Y":271.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":242893.0,"EndTime":242893.0,"X":197.600739,"Y":311.753632,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":243301.0,"EndTime":243301.0,"X":289.959,"Y":243.180267,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":243449.0,"Objects":[{"StartTime":243449.0,"EndTime":243449.0,"X":289.0,"Y":243.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":243746.0,"EndTime":243746.0,"X":372.825134,"Y":264.0362,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":244004.0,"Objects":[{"StartTime":244004.0,"EndTime":244004.0,"X":465.0,"Y":186.0}]},{"StartTime":244226.0,"Objects":[{"StartTime":244226.0,"EndTime":244226.0,"X":409.0,"Y":80.0}]},{"StartTime":244337.0,"Objects":[{"StartTime":244337.0,"EndTime":244337.0,"X":379.0,"Y":82.0}]},{"StartTime":244449.0,"Objects":[{"StartTime":244449.0,"EndTime":244449.0,"X":349.0,"Y":84.0}]},{"StartTime":244560.0,"Objects":[{"StartTime":244560.0,"EndTime":244560.0,"X":321.0,"Y":87.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":244635.0,"EndTime":244635.0,"X":291.095551,"Y":89.39236,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":244782.0,"Objects":[{"StartTime":244782.0,"EndTime":244782.0,"X":160.0,"Y":48.0}]},{"StartTime":244893.0,"Objects":[{"StartTime":244893.0,"EndTime":244893.0,"X":152.0,"Y":72.0}]},{"StartTime":245004.0,"Objects":[{"StartTime":245004.0,"EndTime":245004.0,"X":144.0,"Y":96.0}]},{"StartTime":245115.0,"Objects":[{"StartTime":245115.0,"EndTime":245115.0,"X":136.0,"Y":120.0}]},{"StartTime":245226.0,"Objects":[{"StartTime":245226.0,"EndTime":245226.0,"X":128.0,"Y":144.0}]},{"StartTime":245337.0,"Objects":[{"StartTime":245337.0,"EndTime":245337.0,"X":72.0,"Y":184.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":245523.0,"EndTime":245523.0,"X":171.634659,"Y":192.540115,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":245671.0,"Objects":[{"StartTime":245671.0,"EndTime":245671.0,"X":208.0,"Y":232.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":245857.0,"EndTime":245857.0,"X":298.983429,"Y":217.017059,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":246004.0,"Objects":[{"StartTime":246004.0,"EndTime":246004.0,"X":320.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":246190.0,"EndTime":246190.0,"X":312.025482,"Y":76.3184738,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":246449.0,"Objects":[{"StartTime":246449.0,"EndTime":246449.0,"X":200.0,"Y":136.0}]},{"StartTime":246671.0,"Objects":[{"StartTime":246671.0,"EndTime":246671.0,"X":304.0,"Y":256.0}]},{"StartTime":246893.0,"Objects":[{"StartTime":246893.0,"EndTime":246893.0,"X":400.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":247079.0,"EndTime":247079.0,"X":499.827454,"Y":114.1278,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":247337.0,"Objects":[{"StartTime":247337.0,"EndTime":247337.0,"X":336.0,"Y":176.0}]},{"StartTime":247449.0,"Objects":[{"StartTime":247449.0,"EndTime":247449.0,"X":336.0,"Y":176.0}]},{"StartTime":247560.0,"Objects":[{"StartTime":247560.0,"EndTime":247560.0,"X":336.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":247746.0,"EndTime":247746.0,"X":244.131042,"Y":160.010208,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":248004.0,"Objects":[{"StartTime":248004.0,"EndTime":248004.0,"X":244.0,"Y":160.0}]},{"StartTime":248226.0,"Objects":[{"StartTime":248226.0,"EndTime":248226.0,"X":80.0,"Y":216.0}]},{"StartTime":248449.0,"Objects":[{"StartTime":248449.0,"EndTime":248449.0,"X":280.0,"Y":256.0}]},{"StartTime":248671.0,"Objects":[{"StartTime":248671.0,"EndTime":248671.0,"X":192.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":248857.0,"EndTime":248857.0,"X":192.0,"Y":180.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":249115.0,"Objects":[{"StartTime":249115.0,"EndTime":249115.0,"X":280.0,"Y":256.0}]},{"StartTime":249337.0,"Objects":[{"StartTime":249337.0,"EndTime":249337.0,"X":392.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":249523.0,"EndTime":249523.0,"X":394.638275,"Y":302.004852,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":249782.0,"Objects":[{"StartTime":249782.0,"EndTime":249782.0,"X":280.0,"Y":256.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":249968.0,"EndTime":249968.0,"X":180.172562,"Y":261.8722,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":250226.0,"Objects":[{"StartTime":250226.0,"EndTime":250226.0,"X":72.0,"Y":184.0}]},{"StartTime":250449.0,"Objects":[{"StartTime":250449.0,"EndTime":250449.0,"X":120.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":250635.0,"EndTime":250635.0,"X":186.695938,"Y":145.538834,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":250893.0,"Objects":[{"StartTime":250893.0,"EndTime":250893.0,"X":136.0,"Y":280.0}]},{"StartTime":251115.0,"Objects":[{"StartTime":251115.0,"EndTime":251115.0,"X":136.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":251523.0,"EndTime":251523.0,"X":335.862976,"Y":272.597656,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":251782.0,"Objects":[{"StartTime":251782.0,"EndTime":251782.0,"X":416.0,"Y":152.0}]},{"StartTime":252004.0,"Objects":[{"StartTime":252004.0,"EndTime":252004.0,"X":488.0,"Y":80.0}]},{"StartTime":252226.0,"Objects":[{"StartTime":252226.0,"EndTime":252226.0,"X":416.0,"Y":8.0}]},{"StartTime":252449.0,"Objects":[{"StartTime":252449.0,"EndTime":252449.0,"X":344.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":252635.0,"EndTime":252635.0,"X":244.25412,"Y":87.1247,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":252782.0,"Objects":[{"StartTime":252782.0,"EndTime":252782.0,"X":192.0,"Y":48.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":252968.0,"EndTime":252968.0,"X":204.3272,"Y":146.078491,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":253115.0,"Objects":[{"StartTime":253115.0,"EndTime":253115.0,"X":152.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":253301.0,"EndTime":253301.0,"X":251.315063,"Y":187.684128,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":253560.0,"Objects":[{"StartTime":253560.0,"EndTime":253560.0,"X":448.0,"Y":176.0}]},{"StartTime":253782.0,"Objects":[{"StartTime":253782.0,"EndTime":253782.0,"X":344.0,"Y":304.0}]},{"StartTime":254004.0,"Objects":[{"StartTime":254004.0,"EndTime":254004.0,"X":272.0,"Y":152.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":254190.0,"EndTime":254190.0,"X":272.0,"Y":52.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":254449.0,"Objects":[{"StartTime":254449.0,"EndTime":254449.0,"X":328.0,"Y":208.0}]},{"StartTime":254671.0,"Objects":[{"StartTime":254671.0,"EndTime":254671.0,"X":328.0,"Y":208.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":254857.0,"EndTime":254857.0,"X":229.762344,"Y":207.021118,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":255115.0,"Objects":[{"StartTime":255115.0,"EndTime":255115.0,"X":72.0,"Y":288.0}]},{"StartTime":255337.0,"Objects":[{"StartTime":255337.0,"EndTime":255337.0,"X":136.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":255412.0,"EndTime":255412.0,"X":118.443825,"Y":121.18354,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":255560.0,"Objects":[{"StartTime":255560.0,"EndTime":255560.0,"X":216.0,"Y":32.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":255635.0,"EndTime":255635.0,"X":229.736053,"Y":80.0762,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":255782.0,"Objects":[{"StartTime":255782.0,"EndTime":255782.0,"X":328.0,"Y":160.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":255857.0,"EndTime":255857.0,"X":347.088,"Y":113.786926,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":256004.0,"Objects":[{"StartTime":256004.0,"EndTime":256004.0,"X":424.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":256031.0,"EndTime":256031.0,"X":447.717072,"Y":231.9057,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":256115.0,"Objects":[{"StartTime":256115.0,"EndTime":256115.0,"X":390.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":256142.0,"EndTime":256142.0,"X":404.859772,"Y":292.1044,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":256226.0,"Objects":[{"StartTime":256226.0,"EndTime":256226.0,"X":333.0,"Y":291.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":256253.0,"EndTime":256253.0,"X":333.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":256337.0,"Objects":[{"StartTime":256337.0,"EndTime":256337.0,"X":277.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":256364.0,"EndTime":256364.0,"X":262.7234,"Y":292.522644,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":256449.0,"Objects":[{"StartTime":256449.0,"EndTime":256449.0,"X":242.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":256476.0,"EndTime":256476.0,"X":218.375351,"Y":232.177765,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":256560.0,"Objects":[{"StartTime":256560.0,"EndTime":256560.0,"X":224.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":256587.0,"EndTime":256587.0,"X":247.717087,"Y":160.0943,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":256671.0,"Objects":[{"StartTime":256671.0,"EndTime":256671.0,"X":190.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":256698.0,"EndTime":256698.0,"X":204.859772,"Y":99.8956,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":256782.0,"Objects":[{"StartTime":256782.0,"EndTime":256782.0,"X":133.0,"Y":101.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":256809.0,"EndTime":256809.0,"X":133.0,"Y":76.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":256893.0,"Objects":[{"StartTime":256893.0,"EndTime":256893.0,"X":77.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":256920.0,"EndTime":256920.0,"X":62.72339,"Y":99.47737,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":257004.0,"Objects":[{"StartTime":257004.0,"EndTime":257004.0,"X":42.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":257031.0,"EndTime":257031.0,"X":18.375349,"Y":159.822235,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":257115.0,"Objects":[{"StartTime":257115.0,"EndTime":257115.0,"X":42.0,"Y":227.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":257142.0,"EndTime":257142.0,"X":18.282917,"Y":234.9057,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":257226.0,"Objects":[{"StartTime":257226.0,"EndTime":257226.0,"X":76.0,"Y":275.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":257253.0,"EndTime":257253.0,"X":61.14022,"Y":295.1044,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":257337.0,"Objects":[{"StartTime":257337.0,"EndTime":257337.0,"X":133.0,"Y":294.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":257364.0,"EndTime":257364.0,"X":133.0,"Y":319.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":257449.0,"Objects":[{"StartTime":257449.0,"EndTime":257449.0,"X":189.0,"Y":275.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":257476.0,"EndTime":257476.0,"X":203.276611,"Y":295.522644,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":257560.0,"Objects":[{"StartTime":257560.0,"EndTime":257560.0,"X":224.0,"Y":227.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":257587.0,"EndTime":257587.0,"X":247.624649,"Y":235.177765,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":257671.0,"Objects":[{"StartTime":257671.0,"EndTime":257671.0,"X":224.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":257698.0,"EndTime":257698.0,"X":247.624649,"Y":159.822235,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":257782.0,"Objects":[{"StartTime":257782.0,"EndTime":257782.0,"X":190.0,"Y":120.0}]},{"StartTime":257893.0,"Objects":[{"StartTime":257893.0,"EndTime":257893.0,"X":133.0,"Y":101.0}]},{"StartTime":258004.0,"Objects":[{"StartTime":258004.0,"EndTime":258004.0,"X":77.0,"Y":120.0}]},{"StartTime":258226.0,"Objects":[{"StartTime":258226.0,"EndTime":258226.0,"X":224.0,"Y":227.0}]},{"StartTime":258337.0,"Objects":[{"StartTime":258337.0,"EndTime":258337.0,"X":258.0,"Y":275.0}]},{"StartTime":258449.0,"Objects":[{"StartTime":258449.0,"EndTime":258449.0,"X":315.0,"Y":294.0}]},{"StartTime":258560.0,"Objects":[{"StartTime":258560.0,"EndTime":258560.0,"X":371.0,"Y":275.0}]},{"StartTime":258671.0,"Objects":[{"StartTime":258671.0,"EndTime":258671.0,"X":406.0,"Y":227.0}]},{"StartTime":258782.0,"Objects":[{"StartTime":258782.0,"EndTime":258782.0,"X":412.0,"Y":167.0}]},{"StartTime":258893.0,"Objects":[{"StartTime":258893.0,"EndTime":258893.0,"X":391.0,"Y":110.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":259004.0,"EndTime":259004.0,"X":341.4725,"Y":103.142349,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":259079.0,"EndTime":259079.0,"X":391.0,"Y":110.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":259226.0,"Objects":[{"StartTime":259226.0,"EndTime":259226.0,"X":315.0,"Y":194.0}]},{"StartTime":259337.0,"Objects":[{"StartTime":259337.0,"EndTime":259337.0,"X":315.0,"Y":194.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":259412.0,"EndTime":259412.0,"X":265.4725,"Y":200.857651,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":259560.0,"Objects":[{"StartTime":259560.0,"EndTime":259560.0,"X":88.0,"Y":95.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":259746.0,"EndTime":259746.0,"X":237.244583,"Y":76.19726,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":259893.0,"Objects":[{"StartTime":259893.0,"EndTime":259893.0,"X":362.0,"Y":269.0}]},{"StartTime":260004.0,"Objects":[{"StartTime":260004.0,"EndTime":260004.0,"X":272.0,"Y":288.0}]},{"StartTime":260115.0,"Objects":[{"StartTime":260115.0,"EndTime":260115.0,"X":194.0,"Y":241.0}]},{"StartTime":260226.0,"Objects":[{"StartTime":260226.0,"EndTime":260226.0,"X":171.0,"Y":153.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":260337.0,"EndTime":260337.0,"X":177.246231,"Y":68.22981,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":260412.0,"EndTime":260412.0,"X":171.0,"Y":153.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":260560.0,"Objects":[{"StartTime":260560.0,"EndTime":260560.0,"X":106.0,"Y":216.0}]},{"StartTime":260671.0,"Objects":[{"StartTime":260671.0,"EndTime":260671.0,"X":24.0,"Y":177.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":260746.0,"EndTime":260746.0,"X":29.0505848,"Y":261.849823,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":260893.0,"Objects":[{"StartTime":260893.0,"EndTime":260893.0,"X":165.0,"Y":349.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":260968.0,"EndTime":260968.0,"X":169.991379,"Y":264.146667,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":261115.0,"Objects":[{"StartTime":261115.0,"EndTime":261115.0,"X":394.0,"Y":84.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":261301.0,"EndTime":261301.0,"X":261.752472,"Y":137.17804,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":261449.0,"Objects":[{"StartTime":261449.0,"EndTime":261449.0,"X":394.0,"Y":84.0}]},{"StartTime":261560.0,"Objects":[{"StartTime":261560.0,"EndTime":261560.0,"X":425.0,"Y":169.0}]},{"StartTime":261671.0,"Objects":[{"StartTime":261671.0,"EndTime":261671.0,"X":388.0,"Y":252.0}]},{"StartTime":261782.0,"Objects":[{"StartTime":261782.0,"EndTime":261782.0,"X":303.0,"Y":285.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":261893.0,"EndTime":261893.0,"X":223.041077,"Y":263.3701,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":261968.0,"EndTime":261968.0,"X":303.0,"Y":285.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":262115.0,"Objects":[{"StartTime":262115.0,"EndTime":262115.0,"X":454.0,"Y":347.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":262301.0,"EndTime":262301.0,"X":462.396332,"Y":177.207474,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":262449.0,"Objects":[{"StartTime":262449.0,"EndTime":262449.0,"X":297.0,"Y":56.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":262524.0,"EndTime":262524.0,"X":309.2367,"Y":137.393311,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":262671.0,"Objects":[{"StartTime":262671.0,"EndTime":262671.0,"X":491.0,"Y":104.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":262746.0,"EndTime":262746.0,"X":410.68988,"Y":116.735062,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":262893.0,"Objects":[{"StartTime":262893.0,"EndTime":262893.0,"X":223.0,"Y":263.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":262968.0,"EndTime":262968.0,"X":216.6031,"Y":178.241058,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":263115.0,"Objects":[{"StartTime":263115.0,"EndTime":263115.0,"X":68.0,"Y":53.0}]},{"StartTime":263226.0,"Objects":[{"StartTime":263226.0,"EndTime":263226.0,"X":89.0,"Y":55.0}]},{"StartTime":263337.0,"Objects":[{"StartTime":263337.0,"EndTime":263337.0,"X":110.0,"Y":57.0}]},{"StartTime":263449.0,"Objects":[{"StartTime":263449.0,"EndTime":263449.0,"X":131.0,"Y":60.0}]},{"StartTime":263560.0,"Objects":[{"StartTime":263560.0,"EndTime":263560.0,"X":152.0,"Y":62.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":263746.0,"EndTime":263746.0,"X":283.057373,"Y":137.823868,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":263893.0,"Objects":[{"StartTime":263893.0,"EndTime":263893.0,"X":87.0,"Y":182.0}]},{"StartTime":264004.0,"Objects":[{"StartTime":264004.0,"EndTime":264004.0,"X":87.0,"Y":182.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":264190.0,"EndTime":264190.0,"X":45.0181046,"Y":329.32666,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":264337.0,"Objects":[{"StartTime":264337.0,"EndTime":264337.0,"X":269.0,"Y":306.0}]},{"StartTime":264449.0,"Objects":[{"StartTime":264449.0,"EndTime":264449.0,"X":269.0,"Y":306.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":264524.0,"EndTime":264524.0,"X":185.536621,"Y":289.910675,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":264671.0,"Objects":[{"StartTime":264671.0,"EndTime":264671.0,"X":283.0,"Y":137.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":264857.0,"EndTime":264857.0,"X":364.216919,"Y":176.3499,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":265004.0,"Objects":[{"StartTime":265004.0,"EndTime":265004.0,"X":165.0,"Y":193.0}]},{"StartTime":265115.0,"Objects":[{"StartTime":265115.0,"EndTime":265115.0,"X":165.0,"Y":193.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":265190.0,"EndTime":265190.0,"X":154.887192,"Y":110.947647,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":265337.0,"Objects":[{"StartTime":265337.0,"EndTime":265337.0,"X":299.0,"Y":286.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":265448.0,"EndTime":265448.0,"X":380.8016,"Y":271.549866,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":265523.0,"EndTime":265523.0,"X":299.0,"Y":286.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":265671.0,"Objects":[{"StartTime":265671.0,"EndTime":265671.0,"X":428.0,"Y":374.0}]},{"StartTime":265782.0,"Objects":[{"StartTime":265782.0,"EndTime":265782.0,"X":487.0,"Y":305.0}]},{"StartTime":265893.0,"Objects":[{"StartTime":265893.0,"EndTime":265893.0,"X":476.0,"Y":215.0}]},{"StartTime":266004.0,"Objects":[{"StartTime":266004.0,"EndTime":266004.0,"X":396.0,"Y":164.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":266079.0,"EndTime":266079.0,"X":311.5542,"Y":173.6905,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":266226.0,"Objects":[{"StartTime":266226.0,"EndTime":266226.0,"X":193.0,"Y":245.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":266301.0,"EndTime":266301.0,"X":108.582191,"Y":235.0685,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":266449.0,"Objects":[{"StartTime":266449.0,"EndTime":266449.0,"X":210.0,"Y":73.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":266524.0,"EndTime":266524.0,"X":291.8972,"Y":59.86789,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":266671.0,"Objects":[{"StartTime":266671.0,"EndTime":266671.0,"X":438.0,"Y":263.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":266857.0,"EndTime":266857.0,"X":334.252533,"Y":211.5238,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":267004.0,"Objects":[{"StartTime":267004.0,"EndTime":267004.0,"X":153.0,"Y":138.0}]},{"StartTime":267115.0,"Objects":[{"StartTime":267115.0,"EndTime":267115.0,"X":66.0,"Y":161.0}]},{"StartTime":267226.0,"Objects":[{"StartTime":267226.0,"EndTime":267226.0,"X":34.0,"Y":245.0}]},{"StartTime":267337.0,"Objects":[{"StartTime":267337.0,"EndTime":267337.0,"X":84.0,"Y":319.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":267523.0,"EndTime":267523.0,"X":253.51619,"Y":307.162628,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":267671.0,"Objects":[{"StartTime":267671.0,"EndTime":267671.0,"X":334.0,"Y":211.0}]},{"StartTime":267782.0,"Objects":[{"StartTime":267782.0,"EndTime":267782.0,"X":334.0,"Y":211.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":267857.0,"EndTime":267857.0,"X":333.140472,"Y":128.340088,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":268004.0,"Objects":[{"StartTime":268004.0,"EndTime":268004.0,"X":458.0,"Y":77.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":268079.0,"EndTime":268079.0,"X":458.859528,"Y":159.659912,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":268226.0,"Objects":[{"StartTime":268226.0,"EndTime":268226.0,"X":321.0,"Y":354.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":268412.0,"EndTime":268412.0,"X":169.8029,"Y":312.272034,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":268560.0,"Objects":[{"StartTime":268560.0,"EndTime":268560.0,"X":34.0,"Y":201.0}]},{"StartTime":268671.0,"Objects":[{"StartTime":268671.0,"EndTime":268671.0,"X":34.0,"Y":202.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":268746.0,"EndTime":268746.0,"X":118.7002,"Y":194.867355,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":268893.0,"Objects":[{"StartTime":268893.0,"EndTime":268893.0,"X":263.0,"Y":87.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":269079.0,"EndTime":269079.0,"X":129.963287,"Y":152.665192,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":269226.0,"Objects":[{"StartTime":269226.0,"EndTime":269226.0,"X":326.0,"Y":305.0}]},{"StartTime":269337.0,"Objects":[{"StartTime":269337.0,"EndTime":269337.0,"X":334.0,"Y":211.0}]},{"StartTime":269449.0,"Objects":[{"StartTime":269449.0,"EndTime":269449.0,"X":343.0,"Y":118.0}]},{"StartTime":269560.0,"Objects":[{"StartTime":269560.0,"EndTime":269560.0,"X":352.0,"Y":25.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":269635.0,"EndTime":269635.0,"X":362.0481,"Y":109.404,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":269782.0,"Objects":[{"StartTime":269782.0,"EndTime":269782.0,"X":223.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":269857.0,"EndTime":269857.0,"X":211.963272,"Y":260.280426,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":270004.0,"Objects":[{"StartTime":270004.0,"EndTime":270004.0,"X":446.0,"Y":266.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":270079.0,"EndTime":270079.0,"X":370.290771,"Y":233.114441,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":270226.0,"Objects":[{"StartTime":270226.0,"EndTime":270226.0,"X":113.0,"Y":288.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":270412.0,"EndTime":270412.0,"X":70.50296,"Y":137.697678,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":270560.0,"Objects":[{"StartTime":270560.0,"EndTime":270560.0,"X":165.0,"Y":55.0}]},{"StartTime":270671.0,"Objects":[{"StartTime":270671.0,"EndTime":270671.0,"X":232.0,"Y":116.0}]},{"StartTime":270782.0,"Objects":[{"StartTime":270782.0,"EndTime":270782.0,"X":323.0,"Y":130.0}]},{"StartTime":270893.0,"Objects":[{"StartTime":270893.0,"EndTime":270893.0,"X":407.0,"Y":90.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":270968.0,"EndTime":270968.0,"X":433.0655,"Y":169.055984,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":271115.0,"Objects":[{"StartTime":271115.0,"EndTime":271115.0,"X":317.0,"Y":319.0}]},{"StartTime":271226.0,"Objects":[{"StartTime":271226.0,"EndTime":271226.0,"X":317.0,"Y":225.0}]},{"StartTime":271337.0,"Objects":[{"StartTime":271337.0,"EndTime":271337.0,"X":406.0,"Y":196.0}]},{"StartTime":271449.0,"Objects":[{"StartTime":271449.0,"EndTime":271449.0,"X":460.0,"Y":272.0}]},{"StartTime":271560.0,"Objects":[{"StartTime":271560.0,"EndTime":271560.0,"X":406.0,"Y":348.0}]},{"StartTime":271671.0,"Objects":[{"StartTime":271671.0,"EndTime":271671.0,"X":317.0,"Y":319.0}]},{"StartTime":271782.0,"Objects":[{"StartTime":271782.0,"EndTime":271782.0,"X":224.0,"Y":303.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":271857.0,"EndTime":271857.0,"X":143.480118,"Y":319.5775,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":272004.0,"Objects":[{"StartTime":272004.0,"EndTime":272004.0,"X":43.0,"Y":164.0}]},{"StartTime":272115.0,"Objects":[{"StartTime":272115.0,"EndTime":272115.0,"X":127.0,"Y":124.0}]},{"StartTime":272226.0,"Objects":[{"StartTime":272226.0,"EndTime":272226.0,"X":117.0,"Y":217.0}]},{"StartTime":272337.0,"Objects":[{"StartTime":272337.0,"EndTime":272337.0,"X":43.0,"Y":164.0}]},{"StartTime":272449.0,"Objects":[{"StartTime":272449.0,"EndTime":272449.0,"X":77.0,"Y":48.0}]},{"StartTime":272560.0,"Objects":[{"StartTime":272560.0,"EndTime":272560.0,"X":157.0,"Y":138.0}]},{"StartTime":272671.0,"Objects":[{"StartTime":272671.0,"EndTime":272671.0,"X":43.0,"Y":164.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":272746.0,"EndTime":272746.0,"X":54.0161133,"Y":82.89177,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":272893.0,"Objects":[{"StartTime":272893.0,"EndTime":272893.0,"X":279.0,"Y":26.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":272968.0,"EndTime":272968.0,"X":363.774475,"Y":19.8120823,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":273115.0,"Objects":[{"StartTime":273115.0,"EndTime":273115.0,"X":504.0,"Y":120.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":273190.0,"EndTime":273190.0,"X":419.225525,"Y":126.18792,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":273337.0,"Objects":[{"StartTime":273337.0,"EndTime":273337.0,"X":176.0,"Y":120.0}]},{"StartTime":273449.0,"Objects":[{"StartTime":273449.0,"EndTime":273449.0,"X":216.0,"Y":208.0}]},{"StartTime":273560.0,"Objects":[{"StartTime":273560.0,"EndTime":273560.0,"X":184.0,"Y":296.0}]},{"StartTime":273671.0,"Objects":[{"StartTime":273671.0,"EndTime":273671.0,"X":112.0,"Y":352.0}]},{"StartTime":273782.0,"Objects":[{"StartTime":273782.0,"EndTime":273782.0,"X":16.0,"Y":344.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":273968.0,"EndTime":273968.0,"X":185.499268,"Y":330.9616,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":274115.0,"Objects":[{"StartTime":274115.0,"EndTime":274115.0,"X":464.0,"Y":288.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":274301.0,"EndTime":274301.0,"X":308.81308,"Y":276.728546,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":274449.0,"Objects":[{"StartTime":274449.0,"EndTime":274449.0,"X":105.0,"Y":186.0}]},{"StartTime":274560.0,"Objects":[{"StartTime":274560.0,"EndTime":274560.0,"X":143.0,"Y":95.0}]},{"StartTime":274671.0,"Objects":[{"StartTime":274671.0,"EndTime":274671.0,"X":233.0,"Y":58.0}]},{"StartTime":274782.0,"Objects":[{"StartTime":274782.0,"EndTime":274782.0,"X":324.0,"Y":95.0}]},{"StartTime":274893.0,"Objects":[{"StartTime":274893.0,"EndTime":274893.0,"X":361.0,"Y":186.0}]},{"StartTime":275004.0,"Objects":[{"StartTime":275004.0,"EndTime":275004.0,"X":324.0,"Y":276.0}]},{"StartTime":275115.0,"Objects":[{"StartTime":275115.0,"EndTime":275115.0,"X":233.0,"Y":314.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":275190.0,"EndTime":275190.0,"X":148.138428,"Y":318.849243,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":275337.0,"Objects":[{"StartTime":275337.0,"EndTime":275337.0,"X":0.0,"Y":216.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":275523.0,"EndTime":275523.0,"X":150.724564,"Y":185.118958,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":275671.0,"Objects":[{"StartTime":275671.0,"EndTime":275671.0,"X":324.0,"Y":276.0}]},{"StartTime":275782.0,"Objects":[{"StartTime":275782.0,"EndTime":275782.0,"X":392.0,"Y":336.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":275857.0,"EndTime":275857.0,"X":414.065216,"Y":257.376373,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":276004.0,"Objects":[{"StartTime":276004.0,"EndTime":276004.0,"X":320.0,"Y":155.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":276190.0,"EndTime":276190.0,"X":323.856537,"Y":275.96756,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":276337.0,"Objects":[{"StartTime":276337.0,"EndTime":276337.0,"X":424.0,"Y":368.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":276523.0,"EndTime":276523.0,"X":455.809052,"Y":201.002441,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":276671.0,"Objects":[{"StartTime":276671.0,"EndTime":276671.0,"X":360.0,"Y":24.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":276746.0,"EndTime":276746.0,"X":373.959381,"Y":100.147621,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":276893.0,"Objects":[{"StartTime":276893.0,"EndTime":276893.0,"X":232.0,"Y":112.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":276968.0,"EndTime":276968.0,"X":155.852371,"Y":125.959389,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":277115.0,"Objects":[{"StartTime":277115.0,"EndTime":277115.0,"X":200.0,"Y":296.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":277190.0,"EndTime":277190.0,"X":213.959381,"Y":219.852386,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":277337.0,"Objects":[{"StartTime":277337.0,"EndTime":277337.0,"X":360.0,"Y":144.0}]},{"StartTime":277449.0,"Objects":[{"StartTime":277449.0,"EndTime":277449.0,"X":360.0,"Y":144.0}]},{"StartTime":277560.0,"Objects":[{"StartTime":277560.0,"EndTime":277560.0,"X":360.0,"Y":144.0}]},{"StartTime":277671.0,"Objects":[{"StartTime":277671.0,"EndTime":277671.0,"X":360.0,"Y":144.0}]},{"StartTime":277782.0,"Objects":[{"StartTime":277782.0,"EndTime":277782.0,"X":360.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":277968.0,"EndTime":277968.0,"X":338.012329,"Y":312.572083,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":278115.0,"Objects":[{"StartTime":278115.0,"EndTime":278115.0,"X":256.0,"Y":352.0}]},{"StartTime":278226.0,"Objects":[{"StartTime":278226.0,"EndTime":278226.0,"X":168.0,"Y":328.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":278412.0,"EndTime":278412.0,"X":307.0137,"Y":274.775055,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":278560.0,"Objects":[{"StartTime":278560.0,"EndTime":278560.0,"X":184.0,"Y":352.0}]},{"StartTime":278671.0,"Objects":[{"StartTime":278671.0,"EndTime":278671.0,"X":184.0,"Y":352.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":278746.0,"EndTime":278746.0,"X":99.25037,"Y":358.5192,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":278893.0,"Objects":[{"StartTime":278893.0,"EndTime":278893.0,"X":307.0,"Y":274.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":279079.0,"EndTime":279079.0,"X":463.852173,"Y":278.797729,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":279226.0,"Objects":[{"StartTime":279226.0,"EndTime":279226.0,"X":504.0,"Y":136.0}]},{"StartTime":279337.0,"Objects":[{"StartTime":279337.0,"EndTime":279337.0,"X":504.0,"Y":137.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":279412.0,"EndTime":279412.0,"X":419.892517,"Y":124.714638,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":279560.0,"Objects":[{"StartTime":279560.0,"EndTime":279560.0,"X":232.0,"Y":64.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":279746.0,"EndTime":279746.0,"X":253.175323,"Y":199.085159,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":279893.0,"Objects":[{"StartTime":279893.0,"EndTime":279893.0,"X":488.0,"Y":264.0}]},{"StartTime":280004.0,"Objects":[{"StartTime":280004.0,"EndTime":280004.0,"X":400.0,"Y":232.0}]},{"StartTime":280115.0,"Objects":[{"StartTime":280115.0,"EndTime":280115.0,"X":312.0,"Y":256.0}]},{"StartTime":280226.0,"Objects":[{"StartTime":280226.0,"EndTime":280226.0,"X":248.0,"Y":320.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":280301.0,"EndTime":280301.0,"X":317.7344,"Y":353.346619,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":280449.0,"Objects":[{"StartTime":280449.0,"EndTime":280449.0,"X":120.0,"Y":280.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":280524.0,"EndTime":280524.0,"X":35.29361,"Y":272.941132,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":280671.0,"Objects":[{"StartTime":280671.0,"EndTime":280671.0,"X":216.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":280746.0,"EndTime":280746.0,"X":298.602722,"Y":192.804947,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":280893.0,"Objects":[{"StartTime":280893.0,"EndTime":280893.0,"X":160.0,"Y":104.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":281079.0,"EndTime":281079.0,"X":16.4957943,"Y":98.49811,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":281226.0,"Objects":[{"StartTime":281226.0,"EndTime":281226.0,"X":201.0,"Y":22.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":281412.0,"EndTime":281412.0,"X":215.117737,"Y":191.412781,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":281560.0,"Objects":[{"StartTime":281560.0,"EndTime":281560.0,"X":376.0,"Y":336.0}]},{"StartTime":281671.0,"Objects":[{"StartTime":281671.0,"EndTime":281671.0,"X":416.0,"Y":248.0}]},{"StartTime":281782.0,"Objects":[{"StartTime":281782.0,"EndTime":281782.0,"X":320.0,"Y":264.0}]},{"StartTime":281893.0,"Objects":[{"StartTime":281893.0,"EndTime":281893.0,"X":376.0,"Y":336.0}]},{"StartTime":282004.0,"Objects":[{"StartTime":282004.0,"EndTime":282004.0,"X":416.0,"Y":248.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":282079.0,"EndTime":282079.0,"X":420.598755,"Y":163.365021,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":282226.0,"Objects":[{"StartTime":282226.0,"EndTime":282226.0,"X":312.0,"Y":56.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":282301.0,"EndTime":282301.0,"X":306.3983,"Y":140.4634,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":282449.0,"Objects":[{"StartTime":282449.0,"EndTime":282449.0,"X":200.0,"Y":256.0}]},{"StartTime":282560.0,"Objects":[{"StartTime":282560.0,"EndTime":282560.0,"X":185.0,"Y":163.0}]},{"StartTime":282671.0,"Objects":[{"StartTime":282671.0,"EndTime":282671.0,"X":92.0,"Y":148.0}]},{"StartTime":282782.0,"Objects":[{"StartTime":282782.0,"EndTime":282782.0,"X":51.0,"Y":231.0}]},{"StartTime":282893.0,"Objects":[{"StartTime":282893.0,"EndTime":282893.0,"X":116.0,"Y":298.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":282968.0,"EndTime":282968.0,"X":200.88652,"Y":302.3907,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":283115.0,"Objects":[{"StartTime":283115.0,"EndTime":283115.0,"X":448.0,"Y":264.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":283301.0,"EndTime":283301.0,"X":290.477661,"Y":254.583908,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":283449.0,"Objects":[{"StartTime":283449.0,"EndTime":283449.0,"X":368.0,"Y":328.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":283635.0,"EndTime":283635.0,"X":385.492859,"Y":158.90239,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":283782.0,"Objects":[{"StartTime":283782.0,"EndTime":283782.0,"X":288.0,"Y":32.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":283857.0,"EndTime":283857.0,"X":298.542938,"Y":116.34362,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":284004.0,"Objects":[{"StartTime":284004.0,"EndTime":284004.0,"X":185.0,"Y":163.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":284079.0,"EndTime":284079.0,"X":136.88089,"Y":92.9318161,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":284226.0,"Objects":[{"StartTime":284226.0,"EndTime":284226.0,"X":51.0,"Y":231.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":284301.0,"EndTime":284301.0,"X":132.353348,"Y":255.629913,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":284449.0,"Objects":[{"StartTime":284449.0,"EndTime":284449.0,"X":360.0,"Y":336.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":284476.0,"EndTime":284476.0,"X":401.231049,"Y":346.30777,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":284560.0,"Objects":[{"StartTime":284560.0,"EndTime":284560.0,"X":384.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":284587.0,"EndTime":284587.0,"X":424.864777,"Y":315.675659,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":284671.0,"Objects":[{"StartTime":284671.0,"EndTime":284671.0,"X":408.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":284698.0,"EndTime":284698.0,"X":449.231049,"Y":282.30777,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":284782.0,"Objects":[{"StartTime":284782.0,"EndTime":284782.0,"X":432.0,"Y":240.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":284809.0,"EndTime":284809.0,"X":473.231049,"Y":250.30777,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":284893.0,"Objects":[{"StartTime":284893.0,"EndTime":284893.0,"X":376.0,"Y":160.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":284920.0,"EndTime":284920.0,"X":334.768951,"Y":170.30777,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":285004.0,"Objects":[{"StartTime":285004.0,"EndTime":285004.0,"X":352.0,"Y":128.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":285031.0,"EndTime":285031.0,"X":311.135223,"Y":139.675644,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":285115.0,"Objects":[{"StartTime":285115.0,"EndTime":285115.0,"X":328.0,"Y":96.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":285142.0,"EndTime":285142.0,"X":286.768951,"Y":106.307762,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":285226.0,"Objects":[{"StartTime":285226.0,"EndTime":285226.0,"X":304.0,"Y":64.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":285253.0,"EndTime":285253.0,"X":262.768951,"Y":74.30776,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":285337.0,"Objects":[{"StartTime":285337.0,"EndTime":285337.0,"X":160.0,"Y":64.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":285364.0,"EndTime":285364.0,"X":160.0,"Y":21.5,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":285449.0,"Objects":[{"StartTime":285449.0,"EndTime":285449.0,"X":112.0,"Y":88.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":285476.0,"EndTime":285476.0,"X":81.94796,"Y":57.94796,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":285560.0,"Objects":[{"StartTime":285560.0,"EndTime":285560.0,"X":84.0,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":285587.0,"EndTime":285587.0,"X":41.5,"Y":136.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":285671.0,"Objects":[{"StartTime":285671.0,"EndTime":285671.0,"X":84.0,"Y":192.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":285698.0,"EndTime":285698.0,"X":53.5699921,"Y":221.66925,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":285782.0,"Objects":[{"StartTime":285782.0,"EndTime":285782.0,"X":176.0,"Y":292.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":285809.0,"EndTime":285809.0,"X":176.0,"Y":334.5,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":285893.0,"Objects":[{"StartTime":285893.0,"EndTime":285893.0,"X":216.0,"Y":264.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":285920.0,"EndTime":285920.0,"X":246.052032,"Y":294.052032,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":286004.0,"Objects":[{"StartTime":286004.0,"EndTime":286004.0,"X":240.0,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":286031.0,"EndTime":286031.0,"X":282.5,"Y":224.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":286115.0,"Objects":[{"StartTime":286115.0,"EndTime":286115.0,"X":252.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":286142.0,"EndTime":286142.0,"X":281.66925,"Y":145.569992,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":286226.0,"Objects":[{"StartTime":286226.0,"EndTime":286226.0,"X":164.0,"Y":104.0}]},{"StartTime":286337.0,"Objects":[{"StartTime":286337.0,"EndTime":286337.0,"X":84.0,"Y":136.0}]},{"StartTime":286449.0,"Objects":[{"StartTime":286449.0,"EndTime":286449.0,"X":52.0,"Y":216.0}]},{"StartTime":286671.0,"Objects":[{"StartTime":286671.0,"EndTime":286671.0,"X":216.0,"Y":264.0}]},{"StartTime":286782.0,"Objects":[{"StartTime":286782.0,"EndTime":286782.0,"X":248.0,"Y":184.0}]},{"StartTime":286893.0,"Objects":[{"StartTime":286893.0,"EndTime":286893.0,"X":328.0,"Y":152.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":286948.0,"EndTime":286948.0,"X":328.0,"Y":109.882881,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":286968.0,"EndTime":286968.0,"X":328.0,"Y":152.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":287115.0,"Objects":[{"StartTime":287115.0,"EndTime":287115.0,"X":400.0,"Y":184.0}]},{"StartTime":287226.0,"Objects":[{"StartTime":287226.0,"EndTime":287226.0,"X":440.0,"Y":264.0}]},{"StartTime":287449.0,"Objects":[{"StartTime":287449.0,"EndTime":287449.0,"X":440.0,"Y":280.0}]},{"StartTime":287671.0,"Objects":[{"StartTime":287671.0,"EndTime":287671.0,"X":440.0,"Y":296.0}]},{"StartTime":288004.0,"Objects":[{"StartTime":288004.0,"EndTime":288004.0,"X":160.0,"Y":176.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":288190.0,"EndTime":288190.0,"X":308.9553,"Y":158.327332,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":288337.0,"Objects":[{"StartTime":288337.0,"EndTime":288337.0,"X":335.0,"Y":290.0}]},{"StartTime":288449.0,"Objects":[{"StartTime":288449.0,"EndTime":288449.0,"X":335.0,"Y":290.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":288524.0,"EndTime":288524.0,"X":260.534241,"Y":281.064117,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":288671.0,"Objects":[{"StartTime":288671.0,"EndTime":288671.0,"X":136.0,"Y":148.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":288746.0,"EndTime":288746.0,"X":117.603783,"Y":220.708862,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":288893.0,"Objects":[{"StartTime":288893.0,"EndTime":288893.0,"X":254.0,"Y":138.0}]},{"StartTime":289004.0,"Objects":[{"StartTime":289004.0,"EndTime":289004.0,"X":223.0,"Y":70.0}]},{"StartTime":289115.0,"Objects":[{"StartTime":289115.0,"EndTime":289115.0,"X":160.0,"Y":33.0}]},{"StartTime":289226.0,"Objects":[{"StartTime":289226.0,"EndTime":289226.0,"X":86.0,"Y":39.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":289301.0,"EndTime":289301.0,"X":33.20437,"Y":89.3222656,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":289449.0,"Objects":[{"StartTime":289449.0,"EndTime":289449.0,"X":117.0,"Y":220.0}]},{"StartTime":289560.0,"Objects":[{"StartTime":289560.0,"EndTime":289560.0,"X":117.0,"Y":220.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":289671.0,"EndTime":289671.0,"X":126.199661,"Y":294.433624,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":289746.0,"EndTime":289746.0,"X":117.0,"Y":220.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":289893.0,"Objects":[{"StartTime":289893.0,"EndTime":289893.0,"X":228.0,"Y":182.0}]},{"StartTime":290004.0,"Objects":[{"StartTime":290004.0,"EndTime":290004.0,"X":268.0,"Y":192.0}]},{"StartTime":290115.0,"Objects":[{"StartTime":290115.0,"EndTime":290115.0,"X":300.0,"Y":161.0}]},{"StartTime":290226.0,"Objects":[{"StartTime":290226.0,"EndTime":290226.0,"X":341.0,"Y":171.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":290301.0,"EndTime":290301.0,"X":413.869263,"Y":153.249786,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":290449.0,"Objects":[{"StartTime":290449.0,"EndTime":290449.0,"X":243.0,"Y":96.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":290524.0,"EndTime":290524.0,"X":315.869263,"Y":78.249794,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":290671.0,"Objects":[{"StartTime":290671.0,"EndTime":290671.0,"X":140.0,"Y":30.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":290746.0,"EndTime":290746.0,"X":67.23931,"Y":48.19017,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":290893.0,"Objects":[{"StartTime":290893.0,"EndTime":290893.0,"X":223.0,"Y":123.0}]},{"StartTime":291004.0,"Objects":[{"StartTime":291004.0,"EndTime":291004.0,"X":223.0,"Y":123.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":291079.0,"EndTime":291079.0,"X":150.239319,"Y":141.19017,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":291226.0,"Objects":[{"StartTime":291226.0,"EndTime":291226.0,"X":306.0,"Y":216.0}]},{"StartTime":291337.0,"Objects":[{"StartTime":291337.0,"EndTime":291337.0,"X":233.0,"Y":234.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":291412.0,"EndTime":291412.0,"X":160.239319,"Y":252.19017,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":291560.0,"Objects":[{"StartTime":291560.0,"EndTime":291560.0,"X":60.0,"Y":82.0}]},{"StartTime":291671.0,"Objects":[{"StartTime":291671.0,"EndTime":291671.0,"X":114.0,"Y":95.0}]},{"StartTime":291782.0,"Objects":[{"StartTime":291782.0,"EndTime":291782.0,"X":169.0,"Y":109.0}]},{"StartTime":291893.0,"Objects":[{"StartTime":291893.0,"EndTime":291893.0,"X":223.0,"Y":123.0}]},{"StartTime":292004.0,"Objects":[{"StartTime":292004.0,"EndTime":292004.0,"X":277.0,"Y":108.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":292079.0,"EndTime":292079.0,"X":348.8415,"Y":92.06539,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":292226.0,"Objects":[{"StartTime":292226.0,"EndTime":292226.0,"X":479.0,"Y":209.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":292301.0,"EndTime":292301.0,"X":500.194,"Y":279.897247,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":292449.0,"Objects":[{"StartTime":292449.0,"EndTime":292449.0,"X":285.0,"Y":299.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":292524.0,"EndTime":292524.0,"X":279.247772,"Y":373.779083,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":292671.0,"Objects":[{"StartTime":292671.0,"EndTime":292671.0,"X":407.0,"Y":190.0}]},{"StartTime":292782.0,"Objects":[{"StartTime":292782.0,"EndTime":292782.0,"X":407.0,"Y":190.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":292857.0,"EndTime":292857.0,"X":412.752228,"Y":264.779083,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":293004.0,"Objects":[{"StartTime":293004.0,"EndTime":293004.0,"X":253.0,"Y":128.0}]},{"StartTime":293115.0,"Objects":[{"StartTime":293115.0,"EndTime":293115.0,"X":248.0,"Y":203.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":293301.0,"EndTime":293301.0,"X":332.128082,"Y":159.941086,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":293449.0,"Objects":[{"StartTime":293449.0,"EndTime":293449.0,"X":482.0,"Y":90.0}]},{"StartTime":293560.0,"Objects":[{"StartTime":293560.0,"EndTime":293560.0,"X":487.0,"Y":164.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":293635.0,"EndTime":293635.0,"X":414.186737,"Y":146.021423,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":293782.0,"Objects":[{"StartTime":293782.0,"EndTime":293782.0,"X":248.0,"Y":203.0}]},{"StartTime":293893.0,"Objects":[{"StartTime":293893.0,"EndTime":293893.0,"X":196.0,"Y":180.0}]},{"StartTime":294004.0,"Objects":[{"StartTime":294004.0,"EndTime":294004.0,"X":140.0,"Y":180.0}]},{"StartTime":294115.0,"Objects":[{"StartTime":294115.0,"EndTime":294115.0,"X":89.0,"Y":202.0}]},{"StartTime":294226.0,"Objects":[{"StartTime":294226.0,"EndTime":294226.0,"X":51.0,"Y":243.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":294412.0,"EndTime":294412.0,"X":49.35009,"Y":164.435638,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":294560.0,"Objects":[{"StartTime":294560.0,"EndTime":294560.0,"X":92.0,"Y":319.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":294635.0,"EndTime":294635.0,"X":166.237274,"Y":309.999329,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":294782.0,"Objects":[{"StartTime":294782.0,"EndTime":294782.0,"X":317.0,"Y":351.0}]},{"StartTime":294893.0,"Objects":[{"StartTime":294893.0,"EndTime":294893.0,"X":399.0,"Y":338.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":294968.0,"EndTime":294968.0,"X":452.137421,"Y":286.427643,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":295115.0,"Objects":[{"StartTime":295115.0,"EndTime":295115.0,"X":281.0,"Y":104.0}]},{"StartTime":295226.0,"Objects":[{"StartTime":295226.0,"EndTime":295226.0,"X":247.0,"Y":147.0}]},{"StartTime":295337.0,"Objects":[{"StartTime":295337.0,"EndTime":295337.0,"X":248.0,"Y":203.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":295412.0,"EndTime":295412.0,"X":322.059174,"Y":206.941391,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":295560.0,"Objects":[{"StartTime":295560.0,"EndTime":295560.0,"X":281.0,"Y":104.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":295746.0,"EndTime":295746.0,"X":247.847549,"Y":202.258026,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":295893.0,"Objects":[{"StartTime":295893.0,"EndTime":295893.0,"X":301.0,"Y":78.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":295968.0,"EndTime":295968.0,"X":227.020447,"Y":65.6700745,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":296115.0,"Objects":[{"StartTime":296115.0,"EndTime":296115.0,"X":322.0,"Y":206.0}]},{"StartTime":296226.0,"Objects":[{"StartTime":296226.0,"EndTime":296226.0,"X":378.0,"Y":203.0}]},{"StartTime":296337.0,"Objects":[{"StartTime":296337.0,"EndTime":296337.0,"X":434.0,"Y":200.0}]},{"StartTime":296449.0,"Objects":[{"StartTime":296449.0,"EndTime":296449.0,"X":490.0,"Y":197.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":296524.0,"EndTime":296524.0,"X":481.9127,"Y":123.79538,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":296671.0,"Objects":[{"StartTime":296671.0,"EndTime":296671.0,"X":384.0,"Y":359.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":296746.0,"EndTime":296746.0,"X":446.2316,"Y":319.729828,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":296893.0,"Objects":[{"StartTime":296893.0,"EndTime":296893.0,"X":295.0,"Y":164.0}]},{"StartTime":297004.0,"Objects":[{"StartTime":297004.0,"EndTime":297004.0,"X":242.0,"Y":144.0}]},{"StartTime":297115.0,"Objects":[{"StartTime":297115.0,"EndTime":297115.0,"X":187.0,"Y":149.0}]},{"StartTime":297226.0,"Objects":[{"StartTime":297226.0,"EndTime":297226.0,"X":140.0,"Y":179.0}]},{"StartTime":297337.0,"Objects":[{"StartTime":297337.0,"EndTime":297337.0,"X":112.0,"Y":227.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":297412.0,"EndTime":297412.0,"X":104.972237,"Y":152.329987,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":297560.0,"Objects":[{"StartTime":297560.0,"EndTime":297560.0,"X":0.0,"Y":63.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":297635.0,"EndTime":297635.0,"X":72.19005,"Y":80.00728,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":297782.0,"Objects":[{"StartTime":297782.0,"EndTime":297782.0,"X":259.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":297893.0,"EndTime":297893.0,"X":186.83139,"Y":35.0842171,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":297968.0,"EndTime":297968.0,"X":259.0,"Y":18.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":298115.0,"Objects":[{"StartTime":298115.0,"EndTime":298115.0,"X":383.0,"Y":73.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":298226.0,"EndTime":298226.0,"X":418.150574,"Y":138.50589,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":298301.0,"EndTime":298301.0,"X":383.0,"Y":73.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":298449.0,"Objects":[{"StartTime":298449.0,"EndTime":298449.0,"X":242.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":298524.0,"EndTime":298524.0,"X":246.777512,"Y":218.847687,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":298671.0,"Objects":[{"StartTime":298671.0,"EndTime":298671.0,"X":409.0,"Y":298.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":298857.0,"EndTime":298857.0,"X":270.00058,"Y":329.671356,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":299115.0,"Objects":[{"StartTime":299115.0,"EndTime":299115.0,"X":47.0,"Y":287.0}]},{"StartTime":299226.0,"Objects":[{"StartTime":299226.0,"EndTime":299226.0,"X":11.0,"Y":243.0}]},{"StartTime":299337.0,"Objects":[{"StartTime":299337.0,"EndTime":299337.0,"X":0.0,"Y":189.0}]},{"StartTime":299449.0,"Objects":[{"StartTime":299449.0,"EndTime":299449.0,"X":13.0,"Y":135.0}]},{"StartTime":299560.0,"Objects":[{"StartTime":299560.0,"EndTime":299560.0,"X":49.0,"Y":92.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":299635.0,"EndTime":299635.0,"X":120.975449,"Y":88.04875,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":299782.0,"Objects":[{"StartTime":299782.0,"EndTime":299782.0,"X":244.0,"Y":186.0}]},{"StartTime":299893.0,"Objects":[{"StartTime":299893.0,"EndTime":299893.0,"X":244.0,"Y":186.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":299968.0,"EndTime":299968.0,"X":172.212662,"Y":189.046677,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":300115.0,"Objects":[{"StartTime":300115.0,"EndTime":300115.0,"X":363.0,"Y":223.0}]},{"StartTime":300226.0,"Objects":[{"StartTime":300226.0,"EndTime":300226.0,"X":363.0,"Y":223.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":300301.0,"EndTime":300301.0,"X":358.369659,"Y":148.143066,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":300449.0,"Objects":[{"StartTime":300449.0,"EndTime":300449.0,"X":449.0,"Y":56.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":300504.0,"EndTime":300504.0,"X":454.151123,"Y":19.08355,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":300560.0,"EndTime":300560.0,"X":449.031219,"Y":55.7762642,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":300579.0,"EndTime":300579.0,"X":454.182343,"Y":18.8598175,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":300671.0,"Objects":[{"StartTime":300671.0,"EndTime":300671.0,"X":403.0,"Y":162.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":300726.0,"EndTime":300726.0,"X":405.425751,"Y":199.195084,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":300782.0,"EndTime":300782.0,"X":403.0147,"Y":162.225418,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":300801.0,"EndTime":300801.0,"X":405.44046,"Y":199.4205,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":300893.0,"Objects":[{"StartTime":300893.0,"EndTime":300893.0,"X":340.0,"Y":71.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":300948.0,"EndTime":300948.0,"X":302.878967,"Y":67.62537,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":301004.0,"EndTime":301004.0,"X":339.775024,"Y":70.9795456,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":301023.0,"EndTime":301023.0,"X":302.654,"Y":67.60491,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":301115.0,"Objects":[{"StartTime":301115.0,"EndTime":301115.0,"X":254.0,"Y":153.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":301170.0,"EndTime":301170.0,"X":216.878983,"Y":156.374649,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":301226.0,"EndTime":301226.0,"X":253.775024,"Y":153.020447,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":301245.0,"EndTime":301245.0,"X":216.654,"Y":156.3951,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":301337.0,"Objects":[{"StartTime":301337.0,"EndTime":301337.0,"X":120.0,"Y":88.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":301364.0,"EndTime":301364.0,"X":90.89572,"Y":64.3527756,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":301449.0,"Objects":[{"StartTime":301449.0,"EndTime":301449.0,"X":66.0,"Y":100.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":301476.0,"EndTime":301476.0,"X":29.76619,"Y":90.33765,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":301560.0,"Objects":[{"StartTime":301560.0,"EndTime":301560.0,"X":30.0,"Y":141.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":301587.0,"EndTime":301587.0,"X":-7.21042252,"Y":145.6513,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":301671.0,"Objects":[{"StartTime":301671.0,"EndTime":301671.0,"X":23.0,"Y":196.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":301698.0,"EndTime":301698.0,"X":-10.5410194,"Y":212.770508,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":301782.0,"Objects":[{"StartTime":301782.0,"EndTime":301782.0,"X":50.0,"Y":245.0}]},{"StartTime":301893.0,"Objects":[{"StartTime":301893.0,"EndTime":301893.0,"X":100.0,"Y":226.0}]},{"StartTime":302004.0,"Objects":[{"StartTime":302004.0,"EndTime":302004.0,"X":146.0,"Y":250.0}]},{"StartTime":302115.0,"Objects":[{"StartTime":302115.0,"EndTime":302115.0,"X":159.0,"Y":300.0}]},{"StartTime":302226.0,"Objects":[{"StartTime":302226.0,"EndTime":302226.0,"X":136.0,"Y":344.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":302412.0,"EndTime":302412.0,"X":117.394791,"Y":195.158325,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":302560.0,"Objects":[{"StartTime":302560.0,"EndTime":302560.0,"X":192.0,"Y":80.0}]},{"StartTime":302671.0,"Objects":[{"StartTime":302671.0,"EndTime":302671.0,"X":192.0,"Y":80.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":302857.0,"EndTime":302857.0,"X":239.899292,"Y":201.07045,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":303004.0,"Objects":[{"StartTime":303004.0,"EndTime":303004.0,"X":146.0,"Y":250.0}]},{"StartTime":303115.0,"Objects":[{"StartTime":303115.0,"EndTime":303115.0,"X":72.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":303190.0,"EndTime":303190.0,"X":54.77756,"Y":232.478348,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":303337.0,"Objects":[{"StartTime":303337.0,"EndTime":303337.0,"X":106.0,"Y":129.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":303523.0,"EndTime":303523.0,"X":145.9509,"Y":250.0719,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":303671.0,"Objects":[{"StartTime":303671.0,"EndTime":303671.0,"X":72.0,"Y":152.0}]},{"StartTime":303782.0,"Objects":[{"StartTime":303782.0,"EndTime":303782.0,"X":72.0,"Y":152.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":303857.0,"EndTime":303857.0,"X":66.2477646,"Y":77.22091,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":304004.0,"Objects":[{"StartTime":304004.0,"EndTime":304004.0,"X":168.0,"Y":0.0}]},{"StartTime":304115.0,"Objects":[{"StartTime":304115.0,"EndTime":304115.0,"X":168.0,"Y":0.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":304190.0,"EndTime":304190.0,"X":173.752243,"Y":74.77909,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":304337.0,"Objects":[{"StartTime":304337.0,"EndTime":304337.0,"X":224.0,"Y":184.0}]},{"StartTime":304449.0,"Objects":[{"StartTime":304449.0,"EndTime":304449.0,"X":159.0,"Y":247.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":304635.0,"EndTime":304635.0,"X":291.3957,"Y":239.238037,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":304782.0,"Objects":[{"StartTime":304782.0,"EndTime":304782.0,"X":428.0,"Y":142.0}]},{"StartTime":304893.0,"Objects":[{"StartTime":304893.0,"EndTime":304893.0,"X":428.0,"Y":142.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":304968.0,"EndTime":304968.0,"X":353.110535,"Y":146.070084,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":305115.0,"Objects":[{"StartTime":305115.0,"EndTime":305115.0,"X":263.0,"Y":73.0}]},{"StartTime":305226.0,"Objects":[{"StartTime":305226.0,"EndTime":305226.0,"X":213.0,"Y":56.0}]},{"StartTime":305337.0,"Objects":[{"StartTime":305337.0,"EndTime":305337.0,"X":161.0,"Y":64.0}]},{"StartTime":305449.0,"Objects":[{"StartTime":305449.0,"EndTime":305449.0,"X":122.0,"Y":99.0}]},{"StartTime":305560.0,"Objects":[{"StartTime":305560.0,"EndTime":305560.0,"X":104.0,"Y":148.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":305635.0,"EndTime":305635.0,"X":121.072205,"Y":221.0311,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":305782.0,"Objects":[{"StartTime":305782.0,"EndTime":305782.0,"X":166.0,"Y":316.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":305968.0,"EndTime":305968.0,"X":21.8029289,"Y":327.180328,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":306115.0,"Objects":[{"StartTime":306115.0,"EndTime":306115.0,"X":309.0,"Y":200.0}]},{"StartTime":306226.0,"Objects":[{"StartTime":306226.0,"EndTime":306226.0,"X":309.0,"Y":200.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":306412.0,"EndTime":306412.0,"X":317.581451,"Y":338.349365,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":306560.0,"Objects":[{"StartTime":306560.0,"EndTime":306560.0,"X":243.0,"Y":278.0}]},{"StartTime":306671.0,"Objects":[{"StartTime":306671.0,"EndTime":306671.0,"X":309.0,"Y":200.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":306746.0,"EndTime":306746.0,"X":383.910919,"Y":196.34581,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":306893.0,"Objects":[{"StartTime":306893.0,"EndTime":306893.0,"X":500.0,"Y":99.0}]},{"StartTime":307004.0,"Objects":[{"StartTime":307004.0,"EndTime":307004.0,"X":447.0,"Y":92.0}]},{"StartTime":307115.0,"Objects":[{"StartTime":307115.0,"EndTime":307115.0,"X":397.0,"Y":75.0}]},{"StartTime":307226.0,"Objects":[{"StartTime":307226.0,"EndTime":307226.0,"X":344.0,"Y":69.0}]},{"StartTime":307337.0,"Objects":[{"StartTime":307337.0,"EndTime":307337.0,"X":296.0,"Y":47.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":307412.0,"EndTime":307412.0,"X":221.672577,"Y":36.9783249,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":307560.0,"Objects":[{"StartTime":307560.0,"EndTime":307560.0,"X":108.0,"Y":8.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":307746.0,"EndTime":307746.0,"X":109.323067,"Y":154.072922,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":307893.0,"Objects":[{"StartTime":307893.0,"EndTime":307893.0,"X":78.0,"Y":336.0}]},{"StartTime":308004.0,"Objects":[{"StartTime":308004.0,"EndTime":308004.0,"X":78.0,"Y":336.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":308190.0,"EndTime":308190.0,"X":24.1577778,"Y":213.150711,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":308337.0,"Objects":[{"StartTime":308337.0,"EndTime":308337.0,"X":194.0,"Y":174.0}]},{"StartTime":308449.0,"Objects":[{"StartTime":308449.0,"EndTime":308449.0,"X":194.0,"Y":174.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":308524.0,"EndTime":308524.0,"X":186.889313,"Y":248.66217,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":308671.0,"Objects":[{"StartTime":308671.0,"EndTime":308671.0,"X":288.0,"Y":113.0}]},{"StartTime":308782.0,"Objects":[{"StartTime":308782.0,"EndTime":308782.0,"X":323.0,"Y":74.0}]},{"StartTime":308893.0,"Objects":[{"StartTime":308893.0,"EndTime":308893.0,"X":373.0,"Y":59.0}]},{"StartTime":309004.0,"Objects":[{"StartTime":309004.0,"EndTime":309004.0,"X":424.0,"Y":70.0}]},{"StartTime":309115.0,"Objects":[{"StartTime":309115.0,"EndTime":309115.0,"X":476.0,"Y":67.0}]},{"StartTime":309226.0,"Objects":[{"StartTime":309226.0,"EndTime":309226.0,"X":462.0,"Y":117.0}]},{"StartTime":309337.0,"Objects":[{"StartTime":309337.0,"EndTime":309337.0,"X":448.0,"Y":167.0}]},{"StartTime":309449.0,"Objects":[{"StartTime":309449.0,"EndTime":309449.0,"X":434.0,"Y":217.0}]},{"StartTime":309560.0,"Objects":[{"StartTime":309560.0,"EndTime":309560.0,"X":460.0,"Y":262.0}]},{"StartTime":309671.0,"Objects":[{"StartTime":309671.0,"EndTime":309671.0,"X":408.0,"Y":253.0}]},{"StartTime":309782.0,"Objects":[{"StartTime":309782.0,"EndTime":309782.0,"X":359.0,"Y":274.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":309857.0,"EndTime":309857.0,"X":348.925232,"Y":344.30542,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":310004.0,"Objects":[{"StartTime":310004.0,"EndTime":310004.0,"X":305.0,"Y":191.0}]},{"StartTime":310115.0,"Objects":[{"StartTime":310115.0,"EndTime":310115.0,"X":305.0,"Y":191.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":310301.0,"EndTime":310301.0,"X":325.611847,"Y":42.4228973,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":310449.0,"Objects":[{"StartTime":310449.0,"EndTime":310449.0,"X":216.0,"Y":296.0}]},{"StartTime":310560.0,"Objects":[{"StartTime":310560.0,"EndTime":310560.0,"X":165.0,"Y":310.0}]},{"StartTime":310671.0,"Objects":[{"StartTime":310671.0,"EndTime":310671.0,"X":118.0,"Y":290.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":310857.0,"EndTime":310857.0,"X":80.842,"Y":168.38945,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":311004.0,"Objects":[{"StartTime":311004.0,"EndTime":311004.0,"X":230.0,"Y":113.0}]},{"StartTime":311115.0,"Objects":[{"StartTime":311115.0,"EndTime":311115.0,"X":230.0,"Y":113.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":311412.0,"EndTime":311412.0,"X":5.562317,"Y":128.897675,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":311560.0,"Objects":[{"StartTime":311560.0,"EndTime":311560.0,"X":95.0,"Y":62.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":311635.0,"EndTime":311635.0,"X":149.100632,"Y":108.046616,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":311782.0,"Objects":[{"StartTime":311782.0,"EndTime":311782.0,"X":169.0,"Y":270.0}]},{"StartTime":311893.0,"Objects":[{"StartTime":311893.0,"EndTime":311893.0,"X":169.0,"Y":270.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":312079.0,"EndTime":312079.0,"X":306.0838,"Y":246.75618,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":312226.0,"Objects":[{"StartTime":312226.0,"EndTime":312226.0,"X":405.0,"Y":327.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":312301.0,"EndTime":312301.0,"X":479.9522,"Y":329.67688,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":312449.0,"Objects":[{"StartTime":312449.0,"EndTime":312449.0,"X":200.0,"Y":285.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":312635.0,"EndTime":312635.0,"X":59.225914,"Y":275.1619,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":312782.0,"Objects":[{"StartTime":312782.0,"EndTime":312782.0,"X":13.0,"Y":105.0}]},{"StartTime":312893.0,"Objects":[{"StartTime":312893.0,"EndTime":312893.0,"X":95.0,"Y":62.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":313190.0,"EndTime":313190.0,"X":319.3316,"Y":79.33034,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":313337.0,"Objects":[{"StartTime":313337.0,"EndTime":313337.0,"X":488.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":313412.0,"EndTime":313412.0,"X":475.216217,"Y":200.466019,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":313560.0,"Objects":[{"StartTime":313560.0,"EndTime":313560.0,"X":360.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":313635.0,"EndTime":313635.0,"X":372.7838,"Y":239.533966,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":313782.0,"Objects":[{"StartTime":313782.0,"EndTime":313782.0,"X":464.0,"Y":312.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":313857.0,"EndTime":313857.0,"X":450.9661,"Y":238.141235,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":314004.0,"Objects":[{"StartTime":314004.0,"EndTime":314004.0,"X":320.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":314079.0,"EndTime":314079.0,"X":333.0339,"Y":217.858765,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":314226.0,"Objects":[{"StartTime":314226.0,"EndTime":314226.0,"X":288.0,"Y":352.0}]},{"StartTime":314337.0,"Objects":[{"StartTime":314337.0,"EndTime":314337.0,"X":248.0,"Y":320.0}]},{"StartTime":314449.0,"Objects":[{"StartTime":314449.0,"EndTime":314449.0,"X":208.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":314476.0,"EndTime":314476.0,"X":170.5,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":314560.0,"Objects":[{"StartTime":314560.0,"EndTime":314560.0,"X":168.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":314587.0,"EndTime":314587.0,"X":130.876892,"Y":309.3033,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":314671.0,"Objects":[{"StartTime":314671.0,"EndTime":314671.0,"X":112.0,"Y":320.0}]},{"StartTime":314782.0,"Objects":[{"StartTime":314782.0,"EndTime":314782.0,"X":64.0,"Y":312.0}]},{"StartTime":314893.0,"Objects":[{"StartTime":314893.0,"EndTime":314893.0,"X":32.0,"Y":272.0}]},{"StartTime":315004.0,"Objects":[{"StartTime":315004.0,"EndTime":315004.0,"X":40.0,"Y":216.0}]},{"StartTime":315115.0,"Objects":[{"StartTime":315115.0,"EndTime":315115.0,"X":72.0,"Y":176.0}]},{"StartTime":315226.0,"Objects":[{"StartTime":315226.0,"EndTime":315226.0,"X":120.0,"Y":160.0}]},{"StartTime":315337.0,"Objects":[{"StartTime":315337.0,"EndTime":315337.0,"X":168.0,"Y":144.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":315364.0,"EndTime":315364.0,"X":203.575623,"Y":132.141464,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":315449.0,"Objects":[{"StartTime":315449.0,"EndTime":315449.0,"X":203.0,"Y":132.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":315476.0,"EndTime":315476.0,"X":238.6336,"Y":120.316849,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":315560.0,"Objects":[{"StartTime":315560.0,"EndTime":315560.0,"X":264.0,"Y":136.0}]},{"StartTime":315671.0,"Objects":[{"StartTime":315671.0,"EndTime":315671.0,"X":296.0,"Y":96.0}]},{"StartTime":315782.0,"Objects":[{"StartTime":315782.0,"EndTime":315782.0,"X":288.0,"Y":48.0}]},{"StartTime":315893.0,"Objects":[{"StartTime":315893.0,"EndTime":315893.0,"X":256.0,"Y":8.0}]},{"StartTime":316115.0,"Objects":[{"StartTime":316115.0,"EndTime":316115.0,"X":256.0,"Y":352.0}]},{"StartTime":316337.0,"Objects":[{"StartTime":316337.0,"EndTime":316337.0,"X":256.0,"Y":8.0}]},{"StartTime":316449.0,"Objects":[{"StartTime":316449.0,"EndTime":316449.0,"X":219.0,"Y":91.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":316635.0,"EndTime":316635.0,"X":314.382172,"Y":76.83485,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":316893.0,"Objects":[{"StartTime":316893.0,"EndTime":316893.0,"X":434.0,"Y":29.0}]},{"StartTime":317004.0,"Objects":[{"StartTime":317004.0,"EndTime":317004.0,"X":437.0,"Y":66.0}]},{"StartTime":317115.0,"Objects":[{"StartTime":317115.0,"EndTime":317115.0,"X":440.0,"Y":103.0}]},{"StartTime":317337.0,"Objects":[{"StartTime":317337.0,"EndTime":317337.0,"X":371.0,"Y":215.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":317448.0,"EndTime":317448.0,"X":321.970978,"Y":205.1942,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":317559.0,"EndTime":317559.0,"X":371.0,"Y":215.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":317634.0,"EndTime":317634.0,"X":321.970978,"Y":205.1942,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":317782.0,"Objects":[{"StartTime":317782.0,"EndTime":317782.0,"X":298.0,"Y":247.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":317893.0,"EndTime":317893.0,"X":248.790833,"Y":255.857651,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":318004.0,"EndTime":318004.0,"X":298.0,"Y":247.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":318079.0,"EndTime":318079.0,"X":248.790833,"Y":255.857651,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":318226.0,"Objects":[{"StartTime":318226.0,"EndTime":318226.0,"X":216.0,"Y":221.0}]},{"StartTime":318337.0,"Objects":[{"StartTime":318337.0,"EndTime":318337.0,"X":178.0,"Y":217.0}]},{"StartTime":318449.0,"Objects":[{"StartTime":318449.0,"EndTime":318449.0,"X":143.0,"Y":229.0}]},{"StartTime":318671.0,"Objects":[{"StartTime":318671.0,"EndTime":318671.0,"X":23.0,"Y":142.0}]},{"StartTime":318893.0,"Objects":[{"StartTime":318893.0,"EndTime":318893.0,"X":161.0,"Y":78.0}]},{"StartTime":319115.0,"Objects":[{"StartTime":319115.0,"EndTime":319115.0,"X":56.0,"Y":224.0}]},{"StartTime":319226.0,"Objects":[{"StartTime":319226.0,"EndTime":319226.0,"X":36.0,"Y":255.0}]},{"StartTime":319337.0,"Objects":[{"StartTime":319337.0,"EndTime":319337.0,"X":31.0,"Y":292.0}]},{"StartTime":319449.0,"Objects":[{"StartTime":319449.0,"EndTime":319449.0,"X":40.0,"Y":328.0}]},{"StartTime":319560.0,"Objects":[{"StartTime":319560.0,"EndTime":319560.0,"X":62.0,"Y":358.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":319635.0,"EndTime":319635.0,"X":110.935852,"Y":347.739258,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":319782.0,"Objects":[{"StartTime":319782.0,"EndTime":319782.0,"X":249.0,"Y":272.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":319968.0,"EndTime":319968.0,"X":347.724121,"Y":287.923248,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":320226.0,"Objects":[{"StartTime":320226.0,"EndTime":320226.0,"X":219.0,"Y":363.0}]},{"StartTime":320449.0,"Objects":[{"StartTime":320449.0,"EndTime":320449.0,"X":260.0,"Y":226.0}]},{"StartTime":320671.0,"Objects":[{"StartTime":320671.0,"EndTime":320671.0,"X":380.0,"Y":331.0}]},{"StartTime":320893.0,"Objects":[{"StartTime":320893.0,"EndTime":320893.0,"X":462.0,"Y":168.0}]},{"StartTime":321004.0,"Objects":[{"StartTime":321004.0,"EndTime":321004.0,"X":420.0,"Y":173.0}]},{"StartTime":321115.0,"Objects":[{"StartTime":321115.0,"EndTime":321115.0,"X":379.0,"Y":178.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":321190.0,"EndTime":321190.0,"X":330.541565,"Y":190.319946,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":321337.0,"Objects":[{"StartTime":321337.0,"EndTime":321337.0,"X":200.0,"Y":259.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":321412.0,"EndTime":321412.0,"X":248.45842,"Y":271.319946,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":321560.0,"Objects":[{"StartTime":321560.0,"EndTime":321560.0,"X":380.0,"Y":331.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":321746.0,"EndTime":321746.0,"X":386.820923,"Y":234.890335,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":322004.0,"Objects":[{"StartTime":322004.0,"EndTime":322004.0,"X":338.0,"Y":49.0}]},{"StartTime":322226.0,"Objects":[{"StartTime":322226.0,"EndTime":322226.0,"X":330.0,"Y":190.0}]},{"StartTime":322449.0,"Objects":[{"StartTime":322449.0,"EndTime":322449.0,"X":443.0,"Y":275.0}]},{"StartTime":322671.0,"Objects":[{"StartTime":322671.0,"EndTime":322671.0,"X":426.0,"Y":118.0}]},{"StartTime":322782.0,"Objects":[{"StartTime":322782.0,"EndTime":322782.0,"X":394.0,"Y":97.0}]},{"StartTime":322893.0,"Objects":[{"StartTime":322893.0,"EndTime":322893.0,"X":360.0,"Y":83.0}]},{"StartTime":323004.0,"Objects":[{"StartTime":323004.0,"EndTime":323004.0,"X":323.0,"Y":76.0}]},{"StartTime":323115.0,"Objects":[{"StartTime":323115.0,"EndTime":323115.0,"X":285.0,"Y":78.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":323190.0,"EndTime":323190.0,"X":236.110245,"Y":84.99362,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":323337.0,"Objects":[{"StartTime":323337.0,"EndTime":323337.0,"X":165.0,"Y":223.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":323412.0,"EndTime":323412.0,"X":191.980072,"Y":181.488983,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":323560.0,"Objects":[{"StartTime":323560.0,"EndTime":323560.0,"X":65.0,"Y":126.0}]},{"StartTime":323671.0,"Objects":[{"StartTime":323671.0,"EndTime":323671.0,"X":43.0,"Y":156.0}]},{"StartTime":323782.0,"Objects":[{"StartTime":323782.0,"EndTime":323782.0,"X":30.0,"Y":191.0}]},{"StartTime":323893.0,"Objects":[{"StartTime":323893.0,"EndTime":323893.0,"X":27.0,"Y":229.0}]},{"StartTime":324004.0,"Objects":[{"StartTime":324004.0,"EndTime":324004.0,"X":34.0,"Y":265.0}]},{"StartTime":324115.0,"Objects":[{"StartTime":324115.0,"EndTime":324115.0,"X":69.0,"Y":278.0}]},{"StartTime":324226.0,"Objects":[{"StartTime":324226.0,"EndTime":324226.0,"X":106.0,"Y":283.0}]},{"StartTime":324337.0,"Objects":[{"StartTime":324337.0,"EndTime":324337.0,"X":144.0,"Y":280.0}]},{"StartTime":324449.0,"Objects":[{"StartTime":324449.0,"EndTime":324449.0,"X":179.0,"Y":269.0}]},{"StartTime":324560.0,"Objects":[{"StartTime":324560.0,"EndTime":324560.0,"X":205.0,"Y":294.0}]},{"StartTime":324671.0,"Objects":[{"StartTime":324671.0,"EndTime":324671.0,"X":205.0,"Y":331.0}]},{"StartTime":324782.0,"Objects":[{"StartTime":324782.0,"EndTime":324782.0,"X":179.0,"Y":356.0}]},{"StartTime":324893.0,"Objects":[{"StartTime":324893.0,"EndTime":324893.0,"X":143.0,"Y":356.0}]},{"StartTime":325004.0,"Objects":[{"StartTime":325004.0,"EndTime":325004.0,"X":120.0,"Y":319.0}]},{"StartTime":325115.0,"Objects":[{"StartTime":325115.0,"EndTime":325115.0,"X":111.0,"Y":276.0}]},{"StartTime":325226.0,"Objects":[{"StartTime":325226.0,"EndTime":325226.0,"X":118.0,"Y":233.0}]},{"StartTime":325337.0,"Objects":[{"StartTime":325337.0,"EndTime":325337.0,"X":139.0,"Y":195.0}]},{"StartTime":325449.0,"Objects":[{"StartTime":325449.0,"EndTime":325449.0,"X":168.0,"Y":203.0}]},{"StartTime":325560.0,"Objects":[{"StartTime":325560.0,"EndTime":325560.0,"X":199.0,"Y":204.0}]},{"StartTime":325671.0,"Objects":[{"StartTime":325671.0,"EndTime":325671.0,"X":230.0,"Y":196.0}]},{"StartTime":325782.0,"Objects":[{"StartTime":325782.0,"EndTime":325782.0,"X":256.0,"Y":180.0}]},{"StartTime":325893.0,"Objects":[{"StartTime":325893.0,"EndTime":325893.0,"X":282.0,"Y":164.0}]},{"StartTime":326004.0,"Objects":[{"StartTime":326004.0,"EndTime":326004.0,"X":313.0,"Y":156.0}]},{"StartTime":326115.0,"Objects":[{"StartTime":326115.0,"EndTime":326115.0,"X":344.0,"Y":157.0}]},{"StartTime":326226.0,"Objects":[{"StartTime":326226.0,"EndTime":326226.0,"X":374.0,"Y":166.0}]},{"StartTime":326337.0,"Objects":[{"StartTime":326337.0,"EndTime":326337.0,"X":334.0,"Y":137.0}]},{"StartTime":326449.0,"Objects":[{"StartTime":326449.0,"EndTime":326449.0,"X":318.0,"Y":90.0}]},{"StartTime":326560.0,"Objects":[{"StartTime":326560.0,"EndTime":326560.0,"X":331.0,"Y":43.0}]},{"StartTime":326671.0,"Objects":[{"StartTime":326671.0,"EndTime":326671.0,"X":370.0,"Y":12.0}]},{"StartTime":326782.0,"Objects":[{"StartTime":326782.0,"EndTime":326782.0,"X":419.0,"Y":9.0}]},{"StartTime":326893.0,"Objects":[{"StartTime":326893.0,"EndTime":326893.0,"X":461.0,"Y":35.0}]},{"StartTime":327004.0,"Objects":[{"StartTime":327004.0,"EndTime":327004.0,"X":480.0,"Y":80.0}]},{"StartTime":327115.0,"Objects":[{"StartTime":327115.0,"EndTime":327115.0,"X":470.0,"Y":128.0}]},{"StartTime":327226.0,"Objects":[{"StartTime":327226.0,"EndTime":327226.0,"X":448.0,"Y":166.0}]},{"StartTime":327337.0,"Objects":[{"StartTime":327337.0,"EndTime":327337.0,"X":444.0,"Y":209.0}]},{"StartTime":327449.0,"Objects":[{"StartTime":327449.0,"EndTime":327449.0,"X":458.0,"Y":250.0}]},{"StartTime":327560.0,"Objects":[{"StartTime":327560.0,"EndTime":327560.0,"X":487.0,"Y":282.0}]},{"StartTime":327671.0,"Objects":[{"StartTime":327671.0,"EndTime":327671.0,"X":475.0,"Y":241.0}]},{"StartTime":327782.0,"Objects":[{"StartTime":327782.0,"EndTime":327782.0,"X":442.0,"Y":213.0}]},{"StartTime":327893.0,"Objects":[{"StartTime":327893.0,"EndTime":327893.0,"X":400.0,"Y":208.0}]},{"StartTime":328004.0,"Objects":[{"StartTime":328004.0,"EndTime":328004.0,"X":361.0,"Y":227.0}]},{"StartTime":328115.0,"Objects":[{"StartTime":328115.0,"EndTime":328115.0,"X":336.0,"Y":258.0}]},{"StartTime":328226.0,"Objects":[{"StartTime":328226.0,"EndTime":328226.0,"X":275.0,"Y":214.0}]},{"StartTime":328337.0,"Objects":[{"StartTime":328337.0,"EndTime":328337.0,"X":261.0,"Y":246.0}]},{"StartTime":328449.0,"Objects":[{"StartTime":328449.0,"EndTime":328449.0,"X":192.0,"Y":227.0}]},{"StartTime":328560.0,"Objects":[{"StartTime":328560.0,"EndTime":328560.0,"X":186.0,"Y":256.0}]},{"StartTime":328671.0,"Objects":[{"StartTime":328671.0,"EndTime":328671.0,"X":114.0,"Y":258.0}]},{"StartTime":328782.0,"Objects":[{"StartTime":328782.0,"EndTime":328782.0,"X":113.0,"Y":282.0}]},{"StartTime":328893.0,"Objects":[{"StartTime":328893.0,"EndTime":328893.0,"X":45.0,"Y":304.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":328948.0,"EndTime":328948.0,"X":24.0972271,"Y":317.4375,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329004.0,"EndTime":329004.0,"X":44.8733177,"Y":304.081451,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329023.0,"EndTime":329023.0,"X":23.9705372,"Y":317.518951,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":329115.0,"Objects":[{"StartTime":329115.0,"EndTime":329115.0,"X":25.0,"Y":203.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329170.0,"EndTime":329170.0,"X":0.34249115,"Y":206.082184,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329226.0,"EndTime":329226.0,"X":24.8505611,"Y":203.018677,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329245.0,"EndTime":329245.0,"X":0.1930542,"Y":206.100861,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":329337.0,"Objects":[{"StartTime":329337.0,"EndTime":329337.0,"X":69.0,"Y":115.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329392.0,"EndTime":329392.0,"X":45.28175,"Y":107.588043,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329448.0,"EndTime":329448.0,"X":68.8562546,"Y":114.955078,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329467.0,"EndTime":329467.0,"X":45.138,"Y":107.543121,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":329560.0,"Objects":[{"StartTime":329560.0,"EndTime":329560.0,"X":154.0,"Y":70.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329615.0,"EndTime":329615.0,"X":135.712555,"Y":53.17555,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329671.0,"EndTime":329671.0,"X":153.88916,"Y":69.89803,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329690.0,"EndTime":329690.0,"X":135.60173,"Y":53.0735855,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":329782.0,"Objects":[{"StartTime":329782.0,"EndTime":329782.0,"X":300.0,"Y":116.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329809.0,"EndTime":329809.0,"X":323.513855,"Y":107.508888,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":329893.0,"Objects":[{"StartTime":329893.0,"EndTime":329893.0,"X":282.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":329920.0,"EndTime":329920.0,"X":297.2108,"Y":187.840164,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":330004.0,"Objects":[{"StartTime":330004.0,"EndTime":330004.0,"X":228.0,"Y":168.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":330031.0,"EndTime":330031.0,"X":213.531326,"Y":188.38768,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":330115.0,"Objects":[{"StartTime":330115.0,"EndTime":330115.0,"X":211.0,"Y":115.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":330142.0,"EndTime":330142.0,"X":187.036591,"Y":107.87574,"StackOffset":{"X":0.0,"Y":0.0}}]},{"StartTime":330226.0,"Objects":[{"StartTime":330226.0,"EndTime":330226.0,"X":256.0,"Y":83.0}]},{"StartTime":330337.0,"Objects":[{"StartTime":330337.0,"EndTime":330337.0,"X":256.0,"Y":133.0}]},{"StartTime":330449.0,"Objects":[{"StartTime":330449.0,"EndTime":330449.0,"X":256.0,"Y":183.0}]},{"StartTime":330560.0,"Objects":[{"StartTime":330560.0,"EndTime":330560.0,"X":256.0,"Y":233.0}]}]} \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/801165.osu b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/801165.osu new file mode 100644 index 000000000000..7357bb1dafb2 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/801165.osu @@ -0,0 +1,1768 @@ +osu file format v14 + +[General] +AudioLeadIn: 0 +PreviewTime: 231110 +Countdown: 0 +SampleSet: Normal +StackLeniency: 0.2 +Mode: 0 +LetterboxInBreaks: 0 +WidescreenStoryboard: 1 + +[Difficulty] +HPDrainRate:6 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9.3 +SliderMultiplier:2 +SliderTickRate:1 + +[Events] +//Background and Video events +//Break Periods +2,14204,16450 +2,117537,127450 +2,188649,195006 +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Layer 4 (Overlay) +//Storyboard Sound Samples + +[TimingPoints] +5338,444.444444444444,4,2,0,50,1,0 +6338,-100,4,2,7,60,0,0 +7115,-100,4,2,7,50,0,0 +13671,-100,4,2,7,60,0,0 +17560,-100,4,2,8,60,0,0 +17782,-100,4,2,7,60,0,0 +18226,-100,4,2,8,60,0,0 +21114,-83.3333333333333,4,2,8,60,0,0 +21337,-100,4,2,8,60,0,0 +22893,-83.3333333333333,4,2,8,60,0,0 +23448,-100,4,2,8,60,0,0 +25337,-66.6666666666667,4,2,8,60,0,0 +25670,-100,4,2,8,60,0,0 +31559,-55.5555555555556,4,2,8,60,0,0 +44448,-100,4,2,8,50,0,0 +44893,-100,4,2,8,55,0,0 +45337,-100,4,2,7,60,0,0 +45670,-200,4,2,8,60,0,0 +46226,-66.6666666666667,4,2,8,65,0,0 +58670,-66.6666666666667,4,2,8,55,0,0 +59115,-66.6666666666667,4,2,8,60,0,0 +59559,-66.6666666666667,4,2,8,65,0,0 +60226,-100,4,2,8,60,0,0 +60893,-100,4,2,8,55,0,0 +68004,-66.6666666666667,4,2,8,65,0,0 +73337,-100,4,2,8,55,0,0 +73781,-100,4,2,8,55,0,0 +73893,-100,4,2,7,55,0,0 +74004,-100,4,2,8,55,0,0 +74115,-100,4,2,7,55,0,0 +74226,-100,4,2,8,55,0,0 +74337,-100,4,2,7,55,0,0 +74448,-100,4,2,8,55,0,0 +82893,-76.9230769230769,4,2,8,50,0,0 +85115,-76.9230769230769,4,2,0,50,0,0 +85337,-100,4,2,8,60,0,0 +85893,-100,4,2,7,60,0,0 +86226,-100,4,2,8,60,0,0 +88893,-58.8235294117647,4,1,8,70,0,1 +102226,-58.8235294117647,4,2,8,70,0,1 +103115,-58.8235294117647,4,1,8,70,0,1 +115337,-50,4,1,8,70,0,1 +115670,-50,4,2,7,70,0,1 +116004,-76.9230769230769,4,2,8,70,0,1 +116559,-76.9230769230769,4,2,7,60,0,1 +117337,-58.8235294117647,4,2,8,70,0,0 +128004,-100,4,2,8,65,0,0 +129781,-100,4,2,8,55,0,0 +130226,-100,4,2,8,60,0,0 +130670,-100,4,2,8,65,0,0 +131337,-100,4,2,8,60,0,0 +132004,-100,4,2,8,55,0,0 +145004,-100,4,2,7,55,0,0 +145115,-100,4,2,8,55,0,0 +145226,-100,4,2,7,55,0,0 +145337,-100,4,2,8,55,0,0 +145448,-100,4,2,7,55,0,0 +145559,-100,4,2,8,55,0,0 +156226,-100,4,2,0,50,0,0 +156448,-100,4,2,8,60,0,0 +157004,-100,4,2,7,60,0,0 +157337,-100,4,2,8,60,0,0 +160004,-58.8235294117647,4,1,8,70,0,1 +186781,-58.8235294117647,4,2,7,70,0,1 +187115,-58.8235294117647,4,1,8,70,0,1 +187670,-58.8235294117647,4,1,7,70,0,1 +188449,-58.8235294117647,4,2,8,70,0,0 +195559,-100,4,2,7,40,0,0 +195670,-100,4,2,8,40,0,0 +202670,-66.6666666666667,4,2,8,70,0,0 +203115,-100,4,2,8,60,0,0 +215559,-100,4,2,7,60,0,0 +216893,-83.3333333333333,4,2,8,60,0,0 +228115,-83.3333333333333,4,2,7,60,0,0 +228226,-83.3333333333333,4,2,8,60,0,0 +228337,-83.3333333333333,4,2,7,60,0,0 +228448,-83.3333333333333,4,2,8,60,0,0 +228781,-83.3333333333333,4,2,7,60,0,0 +228893,-83.3333333333333,4,2,8,60,0,0 +229893,-83.3333333333333,4,2,7,60,0,0 +230004,-83.3333333333333,4,2,8,60,0,0 +231115,-166.666666666667,4,2,8,60,0,0 +238226,-166.666666666667,4,2,7,60,0,0 +238893,-166.666666666667,4,2,8,60,0,0 +245004,-166.666666666667,4,2,7,60,0,0 +245115,-166.666666666667,4,2,8,60,0,0 +245337,-100,4,2,8,60,0,0 +257893,-100,4,2,7,60,0,0 +258226,-100,4,2,8,60,0,0 +258337,-100,4,2,7,60,0,0 +258448,-100,4,2,8,60,0,0 +258559,-100,4,2,7,60,0,0 +258670,-100,4,2,8,60,0,0 +259560,-58.8235294117647,4,1,8,70,0,1 +273448,-58.8235294117647,4,2,7,70,0,1 +273559,-58.8235294117647,4,1,8,70,0,1 +286337,-58.8235294117647,4,2,7,70,0,1 +286670,-58.8235294117647,4,2,8,70,0,1 +286781,-58.8235294117647,4,2,7,70,0,1 +286893,-58.8235294117647,4,2,8,70,0,1 +287226,-58.8235294117647,4,1,7,70,0,1 +288004,-66.6666666666667,4,2,8,65,0,0 +295559,-50,4,2,8,65,0,0 +295893,-66.6666666666667,4,2,8,65,0,0 +298448,-66.6666666666667,4,2,0,65,0,0 +298559,-66.6666666666667,4,2,8,65,0,0 +312670,-66.6666666666667,4,2,0,65,0,0 +312781,-66.6666666666667,4,2,8,65,0,0 +313448,-66.6666666666667,4,2,7,65,0,0 +313781,-66.6666666666667,4,2,8,65,0,0 +315337,-66.6666666666667,4,2,7,65,0,0 +315559,-66.6666666666667,4,2,8,65,0,0 +316448,-100,4,2,8,60,0,0 +330559,-100,4,2,7,60,0,0 + + +[Colours] +Combo1 : 139,159,214 +Combo2 : 128,128,255 +Combo3 : 227,227,227 + +[HitObjects] +378,163,6337,6,4,P|427:159|468:188,2,100,8|8|8,3:2|3:2|3:2,0:0:0:0: +320,312,7115,2,0,L|332:360,1,50,6|2,0:0|0:0,0:0:0:0: +164,208,7560,6,0,L|132:208,1,25,10|0,0:0|0:0,0:0:0:0: +139,208,7671,2,0,L|92:208,1,25,2|2,0:0|0:0,0:0:0:0: +114,208,7782,2,0,L|84:208,1,25,2|2,0:0|0:0,0:0:0:0: +89,208,7893,2,0,L|60:208,1,25,10|0,0:0|0:0,0:0:0:0: +32,184,8004,6,0,L|48:112,1,50,2|10,0:0|0:0,0:0:0:0: +144,128,8226,2,0,L|160:56,1,50,8|0,3:2|1:0,0:0:0:0: +266,68,8449,5,2,0:0:0:0: +306,40,8560,1,2,1:2:0:0: +356,39,8671,1,8,3:2:0:0: +397,66,8782,1,0,0:0:0:0: +415,111,8893,6,0,L|447:168,1,50,2|10,3:2|0:0,0:0:0:0: +429,263,9115,2,0,L|463:321,1,50,2|0,0:0|0:0,0:0:0:0: +328,272,9337,6,0,L|312:244,1,25,2|2,0:0|0:0,0:0:0:0: +315,250,9449,2,0,L|291:209,1,25,2|2,0:0|0:0,0:0:0:0: +303,228,9560,2,0,L|288:202,1,25,2|2,0:0|0:0,0:0:0:0: +290,207,9671,2,0,L|275:181,1,25,2|2,0:0|0:0,0:0:0:0: +248,152,9782,6,0,L|248:88,1,50,2|10,0:0|0:0,0:0:0:0: +154,136,10004,2,0,L|154:80,1,50,8|0,3:2|1:0,0:0:0:0: +64,168,10226,5,2,0:0:0:0: +19,187,10337,1,2,1:2:0:0: +0,232,10449,1,8,3:2:0:0: +14,278,10560,1,0,0:0:0:0: +56,303,10671,5,2,3:2:0:0: +101,282,10782,1,10,0:0:0:0: +151,282,10893,1,2,0:0:0:0: +196,302,11004,1,0,0:0:0:0: +240,316,11115,6,0,L|272:316,1,25,10|0,0:0|0:0,0:0:0:0: +265,316,11226,2,0,L|312:316,1,25,2|2,0:0|0:0,0:0:0:0: +290,316,11337,2,0,L|320:316,1,25,2|2,0:0|0:0,0:0:0:0: +315,316,11449,2,0,L|344:316,1,25,10|0,0:0|0:0,0:0:0:0: +384,340,11560,6,0,L|416:292,1,50,2|10,0:0|0:0,0:0:0:0: +376,196,11782,2,0,L|344:244,1,50,10|0,3:0|1:0,0:0:0:0: +416,308,12004,6,0,L|429:251,1,50,2|2,0:0|1:2,0:0:0:0: +359,175,12226,2,0,L|345:231,1,50,10|0,3:0|0:0,0:0:0:0: +440,260,12449,6,0,L|433:202,1,50,2|10,3:2|0:0,0:0:0:0: +340,154,12671,2,0,L|346:211,1,50,2|0,0:3|0:0,0:0:0:0: +432,124,12893,6,0,L|420:94,1,25,10|0,0:0|0:0,0:0:0:0: +422,100,13004,2,0,L|404:56,1,25,2|2,0:0|0:0,0:0:0:0: +412,76,13115,2,0,L|400:48,1,25,2|2,0:0|0:0,0:0:0:0: +403,53,13226,2,0,L|392:26,1,25,10|0,0:0|0:0,0:0:0:0: +352,4,13337,5,2,0:0:0:0: +352,4,13449,1,10,0:0:0:0: +352,4,13560,1,8,3:2:0:0: +352,4,13671,6,0,L|324:96,1,50,0|0,1:0|0:0,0:0:0:0: +257,89,13893,2,0,L|229:-3,1,50,0|0,1:0|0:0,0:0:0:0: +66,72,17004,5,8,3:2:0:0: +128,199,17226,1,8,3:2:0:0: +207,82,17449,1,8,3:2:0:0: +283,160,17560,6,0,L|345:154,1,50,8|0,0:0|2:0,0:0:0:0: +487,113,17782,6,0,L|437:107,3,50,4|0|0|0,0:2|0:0|0:0|0:0,0:0:0:0: +403,76,18226,1,8,0:0:0:0: +365,107,18337,1,0,0:0:0:0: +356,156,18449,1,0,0:0:0:0: +379,199,18560,1,0,0:0:0:0: +423,222,18671,6,0,L|473:228,1,50,8|0,0:0|0:0,0:0:0:0: +332,345,18893,1,0,0:0:0:0: +332,345,19004,2,0,L|322:279,1,50,0|8,0:0|0:0,0:0:0:0: +211,233,19226,1,0,0:0:0:0: +211,233,19337,2,0,L|201:299,1,50 +91,298,19560,6,0,L|28:291,1,50,8|0,0:0|0:0,0:0:0:0: +166,184,19782,2,0,L|178:253,1,50 +220,356,20004,1,8,0:0:0:0: +263,331,20115,1,0,0:0:0:0: +312,322,20226,1,0,0:0:0:0: +361,329,20337,2,0,L|394:368,1,50,0|8,0:0|0:0,0:0:0:0: +457,201,20560,5,0,0:0:0:0: +457,201,20671,1,0,0:0:0:0: +457,201,20782,2,0,L|460:132,2,50,2|8|0,0:0|0:0|0:0,0:0:0:0: +344,244,21115,2,0,L|335:178,1,59.9999981689454,2|2,0:0|0:0,0:0:0:0: +214,70,21337,5,8,3:2:0:0: +263,76,21449,1,0,3:0:0:0: +313,82,21560,1,0,3:0:0:0: +362,88,21671,2,0,L|263:113,1,100,0|0,3:0|3:0,0:0:0:0: +164,64,22004,1,0,3:0:0:0: +164,64,22115,2,0,L|92:55,1,50,0|8,3:0|0:0,0:0:0:0: +21,221,22337,5,0,3:0:0:0: +69,229,22449,1,0,3:0:0:0: +114,208,22560,1,0,3:0:0:0: +139,166,22671,1,10,3:2:0:0: +145,226,22782,1,2,3:2:0:0: +150,286,22893,2,0,P|210:292|268:248,1,119.999996337891,2|8,3:2|0:0,0:0:0:0: +384,243,23226,5,0,0:0:0:0: +384,243,23337,1,0,0:0:0:0: +384,243,23449,2,0,L|389:193,2,50,0|8|0,0:0|0:0|0:0,0:0:0:0: +334,331,23782,1,0,0:0:0:0: +285,319,23893,1,0,0:0:0:0: +236,325,24004,1,10,0:2:0:0: +191,346,24115,1,2,0:0:0:0: +155,381,24226,6,0,L|42:371,1,100,2|10,0:0|0:2,0:0:0:0: +148,254,24560,1,2,0:0:0:0: +148,254,24671,2,0,L|261:243,1,100,2|8,0:0|0:0,0:0:0:0: +90,134,25115,6,0,L|98:196,1,50 +30,218,25337,2,0,B|45:131|45:131|42:126|42:126|46:123|46:123|57:64,1,150.000005722046,2|2,1:0|0:0,0:0:0:0: +179,120,25671,6,0,P|234:119|291:93,1,100 +349,47,26004,1,0,0:0:0:0: +349,47,26115,2,0,P|325:96|342:165,1,100 +411,158,26449,1,0,0:0:0:0: +411,158,26560,2,0,P|435:109|418:40,1,100 +351,96,26893,1,0,0:0:0:0: +384,255,27115,5,8,0:0:0:0: +384,255,27226,1,0,0:0:0:0: +384,255,27337,1,0,0:0:0:0: +384,255,27449,2,0,L|447:250,2,50,0|8|0,0:0|0:0|0:0,0:0:0:0: +228,173,27782,6,0,L|176:209,1,50,2|2,0:0|0:0,0:0:0:0: +279,342,28004,1,8,0:0:0:0: +279,342,28115,1,0,0:0:0:0: +279,342,28226,2,0,L|280:281,1,50,2|2,0:0|0:0,0:0:0:0: +357,136,28449,5,8,3:2:0:0: +307,139,28560,1,0,3:0:0:0: +257,142,28671,1,0,3:0:0:0: +207,145,28782,2,0,L|151:150,2,50,0|8|0,3:0|0:0|3:0,0:0:0:0: +257,142,29115,5,0,3:0:0:0: +307,139,29226,2,0,L|409:161,1,100,0|0,3:0|3:0,0:0:0:0: +445,188,29560,1,0,3:0:0:0: +468,231,29671,1,0,3:0:0:0: +464,280,29782,1,10,3:2:0:0: +435,320,29893,1,2,3:2:0:0: +389,339,30004,2,0,L|329:327,1,50,10|0,3:2|0:0,0:0:0:0: +177,222,30226,6,0,L|146:193,1,25,8|0,0:0|3:0,3:0:0:0: +145,248,30337,2,0,L|102:231,1,25,8|0,0:0|3:0,0:0:0:0: +130,287,30449,2,0,L|90:290,1,25,8|0,0:0|3:0,0:0:0:0: +134,328,30560,2,0,B|174:349|174:349|239:343,1,100,8|0,1:2|3:0,0:0:0:0: +294,251,30893,6,0,L|290:192,1,50,0|0,1:0|3:0,0:0:0:0: +226,74,31115,2,0,L|222:133,1,50,4|0,0:1|3:0,0:0:0:0: +359,150,31337,1,4,0:1:0:0: +359,150,31782,6,0,P|408:124|464:148,1,89.9999972534181,0|0,1:0|1:0,0:0:0:0: +340,240,32004,6,0,L|288:36,1,179.999994506836,12|0,0:0|0:0,0:0:0:0: +176,132,32449,2,0,P|87:100|7:180,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +139,383,32893,2,0,P|83:316|104:232,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +205,291,33337,6,0,L|449:323,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +506,223,33782,2,0,L|262:191,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +182,205,34226,6,0,P|261:157|336:244,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +76,191,34671,2,0,P|28:112|115:37,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +182,205,35115,6,2,L|155:305,1,89.9999972534181,10|0,0:0|0:0,0:1:0:0: +257,361,35337,2,2,L|284:261,1,89.9999972534181,2|0,0:0|0:0,0:1:0:0: +334,174,35560,6,0,P|339:277|458:283,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +447,191,36004,2,0,L|505:21,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +334,174,36449,6,0,P|269:112|169:165,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +133,25,36893,2,0,L|165:279,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +22,281,37337,6,0,P|91:338|207:265,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +306,240,37782,2,0,P|239:184|155:203,1,179.999994506836,8|0,0:0|0:0,0:0:0:0: +0,92,38226,2,0,L|266:54,1,179.999994506836,8|8,0:0|0:0,0:0:0:0: +363,43,38671,6,2,P|386:76|392:133,1,89.9999972534181,10|0,0:0|0:0,0:1:0:0: +306,240,38893,2,2,P|283:207|277:150,1,89.9999972534181,10|0,0:0|0:0,0:1:0:0: +421,293,39115,5,8,3:2:0:0: +368,278,39226,1,0,3:0:0:0: +313,273,39337,1,2,3:3:0:0: +259,284,39449,1,0,0:0:0:0: +214,316,39560,1,8,3:2:0:0: +166,343,39671,1,0,3:0:0:0: +112,332,39782,1,2,3:3:0:0: +76,289,39893,1,0,0:0:0:0: +68,234,40004,1,10,3:2:0:0: +94,185,40115,1,0,3:0:0:0: +136,149,40226,1,0,3:0:0:0: +190,147,40337,1,2,3:0:0:0: +241,167,40449,1,8,0:0:0:0: +261,217,40560,1,0,3:0:0:0: +240,267,40671,1,2,3:0:0:0: +188,285,40782,1,0,0:0:0:0: +135,268,40893,5,8,3:2:0:0: +114,216,41004,1,0,3:0:0:0: +137,166,41115,1,2,3:0:0:0: +190,147,41226,1,0,0:0:0:0: +241,167,41337,1,8,3:2:0:0: +295,170,41449,1,0,3:0:0:0: +348,157,41560,1,2,3:0:0:0: +390,121,41671,1,0,0:0:0:0: +394,66,41782,1,10,3:2:0:0: +364,18,41893,1,0,3:0:0:0: +316,0,42004,1,0,3:0:0:0: +262,11,42115,1,2,3:0:0:0: +214,26,42226,6,2,L|107:43,1,89.9999972534181,8|2,0:0|3:0,0:1:0:0: +2,149,42449,2,2,L|109:166,1,89.9999972534181,4|0,3:2|0:0,0:1:0:0: +336,200,42671,5,12,3:2:0:0: +288,172,42782,1,0,3:0:0:0: +232,173,42893,1,0,3:0:0:0: +189,207,43004,1,0,0:0:0:0: +160,255,43115,1,10,3:0:0:0: +168,308,43226,1,0,3:0:0:0: +196,355,43337,1,2,3:0:0:0: +249,366,43449,1,0,0:0:0:0: +295,337,43560,1,8,3:2:0:0: +303,283,43671,1,0,3:0:0:0: +277,233,43782,1,0,3:0:0:0: +224,216,43893,1,2,3:0:0:0: +172,228,44004,1,8,0:0:0:0: +124,248,44115,1,0,0:0:0:0: +72,232,44226,1,2,3:0:0:0: +32,196,44337,1,0,3:0:0:0: +28,144,44449,6,0,L|1:135,1,25,8|0,0:0|0:0,0:0:0:0: +62,96,44560,2,0,L|45:73,1,25 +119,77,44671,2,0,L|119:49,1,25,8|0,0:0|0:0,0:0:0:0: +175,96,44782,2,0,L|191:73,1,25 +210,144,44893,2,0,L|236:135,1,25,8|0,0:0|0:0,0:0:0:0: +228,200,45004,2,0,L|201:209,1,25,8|0,0:0|0:0,0:0:0:0: +262,248,45115,2,0,L|245:271,1,25,8|0,0:0|0:0,0:0:0:0: +319,267,45226,2,0,L|319:295,1,25,8|0,0:0|0:0,0:0:0:0: +375,248,45337,2,8,L|391:271,1,25 +410,200,45449,2,8,L|436:209,1,25 +410,141,45560,2,8,L|436:132,1,25 +375,93,45671,5,12,0:3:0:0: +375,93,46004,2,0,L|317:86,1,50,0|12,1:0|0:2,0:0:0:0: +317,167,46337,5,0,0:0:0:0: +317,167,46449,2,0,L|160:184,1,150.000005722046,2|8,0:0|0:0,0:0:0:0: +53,101,46782,1,0,0:0:0:0: +108,98,46893,1,0,1:0:0:0: +152,130,47004,1,0,0:0:0:0: +167,183,47115,6,2,L|161:268,1,75.0000028610231,10|0,0:2|0:0,0:1:0:0: +49,308,47337,1,0,1:0:0:0: +49,308,47449,2,2,L|44:204,1,75.0000028610231,2|8,0:0|0:0,0:1:0:0: +205,140,47671,1,0,0:0:0:0: +205,140,47782,2,2,L|210:244,1,75.0000028610231,2|0,1:2|0:0,0:1:0:0: +310,353,48004,5,8,0:0:0:0: +346,347,48115,1,0,0:0:0:0: +383,341,48226,2,0,P|440:289|426:219,1,150.000005722046,2|8,1:2|0:0,0:0:0:0: +317,167,48560,1,0,0:0:0:0: +307,112,48671,1,0,1:0:0:0: +333,64,48782,1,0,0:0:0:0: +384,43,48893,6,2,L|468:54,1,75.0000028610231,10|0,0:2|0:0,0:1:0:0: +506,161,49115,1,0,1:0:0:0: +506,161,49226,2,2,L|422:150,1,75.0000028610231,2|8,0:0|0:0,0:1:0:0: +268,121,49449,1,0,0:0:0:0: +268,121,49560,2,2,L|352:110,1,75.0000028610231,2|0,1:2|0:0,0:1:0:0: +263,263,49782,5,8,0:0:0:0: +228,247,49893,1,0,0:0:0:0: +193,232,50004,2,0,P|128:234|53:312,1,150.000005722046,2|8,1:2|0:0,0:0:0:0: +121,164,50337,1,0,0:0:0:0: +120,109,50449,1,0,1:0:0:0: +91,62,50560,1,0,0:0:0:0: +42,37,50671,6,2,L|123:29,1,75.0000028610231,10|0,0:2|0:0,0:1:0:0: +242,84,50893,1,0,1:0:0:0: +242,84,51004,2,2,L|321:118,1,75.0000028610231,2|8,0:0|0:0,0:1:0:0: +341,245,51226,1,0,0:0:0:0: +341,245,51337,2,2,L|353:331,1,75.0000028610231,2|0,1:2|0:0,0:1:0:0: +163,192,51560,5,8,0:0:0:0: +198,181,51671,1,0,0:0:0:0: +233,170,51782,2,0,P|303:163|372:219,1,150.000005722046,2|8,1:2|0:0,0:0:0:0: +458,297,52115,1,0,0:0:0:0: +460,240,52226,1,0,1:0:0:0: +463,184,52337,1,0,0:0:0:0: +466,128,52449,6,0,B|452:65|452:65|416:155,1,150.000005722046,10|2,0:2|1:2,0:0:0:0: +272,189,52782,2,0,L|197:180,1,75.0000028610231,2|8,0:0|0:0,0:1:0:0: +338,25,53004,1,2,0:0:0:0: +338,25,53115,2,0,L|345:109,1,75.0000028610231,10|0,1:2|0:0,0:1:0:0: +179,225,53337,5,8,0:0:0:0: +175,187,53449,1,0,0:0:0:0: +172,149,53560,2,0,L|320:166,1,150.000005722046,2|8,1:2|0:0,0:0:0:0: +296,293,53893,1,0,0:0:0:0: +259,334,54004,1,0,1:0:0:0: +209,357,54115,1,0,0:0:0:0: +154,358,54226,6,2,L|79:349,1,75.0000028610231,10|0,0:2|0:0,0:1:0:0: +179,225,54449,1,0,1:0:0:0: +179,225,54560,2,2,L|253:233,1,75.0000028610231,2|8,0:0|0:0,0:1:0:0: +91,140,54782,1,0,0:0:0:0: +91,140,54893,2,2,L|16:148,1,75.0000028610231,2|0,1:2|0:0,0:1:0:0: +191,43,55115,5,8,0:0:0:0: +195,80,55226,1,0,0:0:0:0: +199,117,55337,2,0,B|179:225|179:225|156:170,1,150.000005722046,2|8,1:2|0:0,0:0:0:0: +289,165,55671,1,0,0:0:0:0: +344,159,55782,1,0,1:0:0:0: +399,154,55893,1,0,0:0:0:0: +454,149,56004,6,2,L|461:235,1,75.0000028610231,10|0,0:2|0:0,0:1:0:0: +359,281,56226,1,0,1:0:0:0: +359,281,56337,2,2,L|365:366,1,75.0000028610231,2|8,0:2|0:0,0:1:0:0: +262,132,56560,1,0,0:0:0:0: +262,132,56671,2,2,L|268:217,1,75.0000028610231,2|0,1:2|0:0,0:1:0:0: +148,358,56893,5,12,0:2:0:0: +110,355,57004,1,0,0:0:0:0: +79,333,57115,2,0,P|134:285|209:317,1,150.000005722046,2|8,1:2|0:0,0:0:0:0: +329,327,57449,1,0,0:0:0:0: +359,281,57560,1,0,1:0:0:0: +364,226,57671,1,0,0:0:0:0: +343,175,57782,6,2,L|246:184,1,75.0000028610231,10|0,0:2|0:0,0:1:0:0: +342,20,58004,1,0,1:0:0:0: +342,20,58115,2,2,L|387:85,1,75.0000028610231,2|8,0:0|0:0,0:1:0:0: +210,97,58337,1,0,0:0:0:0: +210,97,58449,2,2,L|243:24,1,75.0000028610231,10|0,1:2|0:0,0:1:0:0: +343,175,58671,6,0,L|305:177,3,37.5000014305115,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +209,209,58893,2,0,L|255:224,3,37.5000014305115,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +316,267,59115,2,0,L|283:301,3,37.5000014305115,8|0|8|0,0:0|0:0|0:0|0:0,0:0:0:0: +211,329,59337,2,0,L|194:274,3,37.5000014305115,8|0|8|0,0:0|0:0|0:0|0:0,0:0:0:0: +103,287,59560,6,0,L|67:275,1,37.5000014305115,0|0,1:0|3:0,0:0:0:0: +108,240,59671,2,0,L|72:228,1,37.5000014305115,0|0,1:0|3:0,0:0:0:0: +113,193,59782,2,0,L|77:181,1,37.5000014305115,0|0,1:0|3:0,0:0:0:0: +120,146,59893,1,12,0:3:0:0: +120,146,60226,6,0,L|131:40,1,100,0|12,1:0|0:2,0:0:0:0: +374,174,60893,5,8,0:0:0:0: +326,323,61115,1,0,3:0:0:0: +462,267,61337,2,0,L|518:267,1,50,8|0,0:0|0:0,0:0:0:0: +344,182,61560,5,0,0:0:0:0: +344,182,61671,2,0,L|297:213,1,50,0|8,3:0|0:0,0:0:0:0: +174,241,61893,1,0,0:0:0:0: +174,241,62004,2,0,L|221:272,1,50,0|0,3:0|0:0,0:0:0:0: +66,161,62226,5,8,0:0:0:0: +104,326,62449,1,0,3:0:0:0: +269,288,62671,1,8,0:0:0:0: +231,122,62893,2,0,L|215:50,1,50,0|0,3:0|0:0,0:0:0:0: +296,0,63115,2,0,L|312:72,1,50,8|0,0:0|0:0,0:0:0:0: +373,120,63337,5,0,0:0:0:0: +373,120,63449,2,0,P|413:112|477:152,1,100,0|0,3:0|3:0,0:0:0:0: +400,216,63782,2,0,L|288:232,1,100,0|8,0:0|0:0,0:0:0:0: +48,160,64226,5,0,3:0:0:0: +216,200,64449,1,8,0:0:0:0: +104,288,64671,1,0,3:0:0:0: +216,200,64893,1,8,0:0:0:0: +160,64,65115,5,0,0:0:0:0: +160,64,65226,2,0,L|160:136,1,50,0|8,3:0|0:0,0:0:0:0: +264,104,65449,1,0,0:0:0:0: +264,104,65560,2,0,L|264:168,1,50,0|0,3:0|0:0,0:0:0:0: +456,160,65782,5,8,0:0:0:0: +288,192,66004,1,0,3:0:0:0: +336,48,66226,1,8,0:0:0:0: +408,296,66449,1,0,3:0:0:0: +196,148,66671,6,0,P|188:215|221:271,1,100,8|8,0:0|0:0,0:0:0:0: +288,192,67004,6,0,L|368:216,1,50,0|8,3:0|0:0,0:0:0:0: +297,308,67226,1,0,3:0:0:0: +297,308,67337,2,0,L|217:332,1,50,12|0,0:0|0:0,0:0:0:0: +107,256,67560,6,0,P|150:234|199:244,1,100,14|0,0:0|0:0,0:0:0:0: +460,300,68004,5,8,0:0:0:0: +407,107,68226,1,0,3:0:0:0: +364,364,68449,1,8,0:0:0:0: +345,18,68671,6,0,P|417:2|505:66,1,150.000005722046,2|8,0:0|0:0,0:0:0:0: +167,18,69115,2,0,P|95:2|7:66,1,150.000005722046,2|8,0:0|0:0,0:0:0:0: +407,107,69560,6,0,P|456:161|445:269,1,150.000005722046,2|8,0:0|0:0,0:0:0:0: +158,202,70004,1,0,0:0:0:0: +354,202,70226,1,8,0:0:0:0: +105,107,70449,2,0,P|55:161|66:269,1,150.000005722046,2|8,0:0|0:0,0:0:0:0: +364,281,70893,6,0,P|418:330|526:319,1,150.000005722046,0|8,0:0|0:0,0:0:0:0: +424,64,71337,6,0,B|434:73|434:73|467:145|424:213|284:257|262:111|277:26|173:-19|99:9|48:91|87:209|211:192|211:192|334:175|373:294|323:376|248:404|162:332,1,1050.00004005432,2|8,3:2|0:0,0:0:0:0: +161,331,73337,6,0,L|201:331,1,25,8|0,0:0|3:0,0:0:0:0: +186,331,73449,2,2,L|233:331,1,25,0|0,3:0|3:0,0:0:0:0: +211,331,73560,2,2,L|257:331,1,25,8|0,0:0|3:0,0:0:0:0: +236,331,73671,2,2,L|273:331,1,25,0|0,3:0|3:0,0:0:0:0: +297,339,73782,6,0,L|377:347,1,50,8|8,0:0|0:0,0:0:0:0: +321,181,74004,2,0,L|313:269,1,50,8|8,0:0|3:2,0:0:0:0: +184,116,74226,2,0,L|176:28,1,50,0|8,0:0|3:2,0:0:0:0: +283,82,74449,6,0,P|332:75|377:94,1,100,8|12,0:0|0:0,0:0:0:0: +150,188,75115,5,8,0:0:0:0: +127,46,75337,1,0,0:0:0:0: +157,218,75560,2,0,P|211:199|262:204,1,100,8|0,0:0|0:0,0:0:0:0: +322,283,75893,2,0,B|370:288|370:288|436:262,1,100,0|0,3:0|3:0,0:0:0:0: +439,170,76226,2,0,P|392:159|356:191,1,100,0|8,3:0|0:0,0:0:0:0: +219,92,76671,5,0,3:0:0:0: +371,22,76893,1,8,0:0:0:0: +356,191,77115,1,0,3:0:0:0: +194,73,77337,2,0,P|144:75|94:82,1,100,8|0,0:0|0:0,0:0:0:0: +15,164,77671,2,0,P|63:173|98:210,1,100,0|0,3:0|3:0,0:0:0:0: +26,302,78004,2,0,L|21:253,1,50 +181,348,78226,6,0,B|221:330|221:330|276:335,1,100,8|0,0:0|3:0,0:0:0:0: +422,231,78671,1,8,0:0:0:0: +435,376,78893,1,0,3:0:0:0: +271,230,79115,2,0,L|281:123,1,100,8|0,0:0|0:0,0:0:0:0: +367,56,79449,2,0,P|393:101|389:159,1,100,0|0,3:0|3:0,0:0:0:0: +280,130,79782,2,0,L|226:137,1,50,0|0,3:0|0:0,0:0:0:0: +104,280,80004,5,8,0:0:0:0: +243,207,80226,1,0,3:0:0:0: +104,134,80449,1,8,0:0:0:0: +243,61,80671,1,0,3:0:0:0: +384,189,80893,6,0,L|330:203,1,50,8|0,0:0|0:0,0:0:0:0: +259,262,81115,2,0,L|211:250,1,50,8|0,0:0|0:0,0:0:0:0: +83,157,81337,1,8,1:2:0:0: +60,186,81449,1,0,3:0:0:0: +48,221,81560,1,8,1:2:0:0: +48,259,81671,1,0,3:0:0:0: +61,294,81782,6,0,P|116:293|162:329,1,100,10|0,1:2|0:0,0:0:0:0: +350,269,82226,1,8,0:0:0:0: +226,187,82449,1,0,3:0:0:0: +265,365,82671,1,8,0:0:0:0: +444,207,82893,2,0,P|449:142|420:85,1,129.999994049073,2|8,0:0|0:0,0:0:0:0: +211,48,83337,6,0,P|159:84|136:144,1,129.999994049073,2|8,0:0|0:0,0:0:0:0: +270,337,83782,2,0,B|297:279|256:269|288:205,1,129.999994049073,2|8,0:0|0:0,0:0:0:0: +427,124,84226,1,0,3:0:0:0: +427,124,84337,1,0,3:0:0:0: +427,124,84449,1,8,0:0:0:0: +136,144,84671,2,0,B|183:228|275:200|275:200|303:273|208:263,1,259.999988098145,2|4,0:0|0:1,0:0:0:0: +235,99,85337,5,12,0:2:0:0: +278,92,85449,1,0,0:0:0:0: +319,109,85560,1,2,0:0:0:0: +174,43,85782,1,10,0:2:0:0: +129,35,85893,1,0,1:0:0:0: +86,47,86004,1,0,3:0:0:0: +52,75,86115,1,0,1:0:0:0: +32,115,86226,6,0,L|37:155,3,25,10|0|0|0,0:2|3:0|3:0|3:0,0:0:0:0: +83,252,86449,2,0,L|87:212,3,25,0|0|2|0,1:0|3:0|3:2|3:0,0:0:0:0: +174,189,86671,6,0,L|235:185,2,50,8|0|8,0:0|3:0|0:0,0:0:0:0: +83,252,87004,1,0,0:0:0:0: +83,252,87115,6,0,L|92:311,1,50,12|2,1:2|3:2,0:0:0:0: +185,379,87337,2,0,L|205:328,1,50,10|0,1:2|3:0,0:0:0:0: +310,279,87560,2,0,L|362:285,1,50,8|8,1:2|1:2,0:0:0:0: +490,357,87782,2,0,L|493:386,2,25,8|0|8,1:2|3:0|1:2,0:0:0:0: +456,208,88004,5,0,1:0:0:0: +456,208,88060,1,0,3:0:0:0: +456,208,88115,2,0,L|472:128,1,50,8|0,1:2|0:0,0:0:0:0: +352,40,88337,2,0,L|368:88,1,50,8|2,1:2|0:0,0:0:0:0: +256,136,88560,1,8,1:2:0:0: +256,136,88671,2,0,L|240:80,1,50 +48,184,88893,6,0,P|120:168|216:224,1,170.000003242493,12|0,0:2|0:0,0:0:0:0: +328,336,89226,1,0,0:0:0:0: +424,328,89337,1,8,0:2:0:0: +472,248,89449,1,0,0:0:0:0: +488,160,89560,6,0,P|440:56|352:48,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +256,104,89893,1,0,0:0:0:0: +168,128,90004,1,0,0:0:0:0: +80,120,90115,1,0,0:0:0:0: +0,88,90226,6,0,P|-8:136|8:216,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +96,336,90449,2,0,P|104:288|88:208,1,85.0000016212464 +173,43,90671,6,0,P|165:85|168:128,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +256,296,90893,2,0,P|264:248|248:168,1,85.0000016212464 +336,72,91115,6,0,L|560:56,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +432,120,91449,1,2,0:2:0:0: +392,200,91560,1,8,0:2:0:0: +432,288,91671,1,0,0:0:0:0: +496,360,91782,5,2,0:2:0:0: +256,296,92004,1,10,0:2:0:0: +16,360,92226,2,0,P|64:272|48:184,1,170.000003242493,2|8,0:2|0:2,0:0:0:0: +120,40,92560,5,0,0:0:0:0: +168,120,92671,1,0,0:0:0:0: +248,168,92782,1,0,0:0:0:0: +328,120,92893,6,0,P|414:120|477:240,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +472,360,93226,1,0,0:0:0:0: +472,360,93337,2,0,P|396:319|396:183,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +328,120,93671,5,0,0:0:0:0: +328,120,93782,2,0,L|312:8,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +93,147,94004,6,0,P|174:128|248:168,1,170.000003242493,2|8,0:2|0:2,0:0:0:0: +448,288,94337,1,0,0:0:0:0: +360,264,94449,1,2,0:2:0:0: +272,288,94560,1,0,0:0:0:0: +216,360,94671,6,0,P|152:304|168:224,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +216,104,95004,5,2,0:2:0:0: +216,104,95115,2,0,L|232:16,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +368,48,95337,2,0,P|408:32|472:48,1,85.0000016212464 +432,224,95560,2,0,P|416:184|432:120,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +320,280,95782,2,0,P|336:320|320:384,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +32,232,96004,6,0,P|112:192|224:240,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +320,280,96337,1,0,0:0:0:0: +408,312,96449,1,8,0:2:0:0: +496,280,96560,1,0,0:0:0:0: +496,280,96671,6,0,L|456:112,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +384,56,97004,1,0,0:0:0:0: +296,24,97115,2,0,P|240:16|160:64,1,85.0000016212464 +104,160,97337,2,0,P|160:168|240:120,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +456,192,97560,6,0,P|384:160|304:248,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +264,288,97893,1,0,0:0:0:0: +176,264,98004,2,0,L|8:280,1,85.0000016212464 +16,88,98226,6,0,P|64:136|168:56,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +328,56,98560,1,2,0:2:0:0: +416,32,98671,1,8,0:2:0:0: +480,104,98782,1,0,0:0:0:0: +456,192,98893,6,0,L|464:280,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +328,360,99115,2,0,L|320:272,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +208,112,99337,2,0,L|216:200,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +64,320,99560,5,8,0:2:0:0: +136,264,99671,1,0,0:0:0:0: +232,256,99782,1,0,0:0:0:0: +320,280,99893,1,0,0:0:0:0: +384,344,100004,6,0,P|408:288|416:248,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +360,72,100226,2,0,P|336:128|328:168,1,85.0000016212464 +240,312,100449,5,8,0:2:0:0: +152,344,100560,1,0,0:0:0:0: +64,320,100671,1,0,0:0:0:0: +8,248,100782,1,0,0:0:0:0: +16,152,100893,1,8,0:2:0:0: +88,88,101004,1,0,0:0:0:0: +184,80,101115,1,0,0:0:0:0: +272,120,101226,1,0,0:0:0:0: +356,176,101337,5,10,0:2:0:0: +456,164,101449,1,0,0:0:0:0: +392,88,101560,1,2,0:2:0:0: +348,184,101671,5,0,0:0:0:0: +448,172,101782,1,10,0:2:0:0: +384,96,101893,1,0,0:0:0:0: +340,192,102004,5,10,0:2:0:0: +440,180,102115,1,0,0:0:0:0: +376,104,102226,2,0,P|329:93|290:112,1,85.0000016212464,10|8,1:2|1:2,0:0:0:0: +344,192,102449,2,0,P|380:226|423:229,1,85.0000016212464,0|8,0:0|0:0,0:0:0:0: +169,239,102671,5,2,1:2:0:0: +80,210,102782,1,0,1:0:0:0: +80,117,102893,1,8,1:2:0:0: +169,88,103004,1,8,1:2:0:0: +224,164,103115,6,0,P|315:166|404:144,1,170.000003242493,12|0,1:2|0:0,0:0:0:0: +456,317,103449,1,0,0:0:0:0: +366,290,103560,1,8,1:2:0:0: +324,206,103671,1,2,0:2:0:0: +326,112,103782,2,0,L|344:10,2,85.0000016212464,0|0|10,0:0|0:0|0:2,0:0:0:0: +314,203,104115,5,0,0:0:0:0: +242,148,104226,2,0,L|184:87,1,85.0000016212464 +398,167,104449,2,0,L|479:122,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +365,276,104671,2,0,L|383:378,2,85.0000016212464,0|0|8,0:0|0:0|0:2,0:0:0:0: +183,86,105004,5,0,0:0:0:0: +99,88,105115,1,0,0:0:0:0: +31,137,105226,1,0,0:0:0:0: +3,216,105337,1,10,0:2:0:0: +24,297,105449,1,0,0:0:0:0: +87,352,105560,1,0,0:0:0:0: +152,298,105671,1,2,0:2:0:0: +233,273,105782,1,8,0:2:0:0: +317,283,105893,1,0,0:0:0:0: +391,324,106004,6,0,L|484:310,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +282,227,106226,2,0,L|189:212,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +46,357,106449,2,0,L|138:342,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +62,140,106671,1,8,0:2:0:0: +62,140,106782,1,0,0:0:0:0: +62,140,106893,1,0,0:0:0:0: +62,140,107004,1,0,0:0:0:0: +62,140,107115,6,0,L|245:116,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +418,227,107449,1,0,0:0:0:0: +330,251,107560,1,8,1:2:0:0: +251,206,107671,1,0,0:0:0:0: +230,117,107782,1,0,0:0:0:0: +277,35,107893,1,0,0:0:0:0: +347,130,108004,2,0,L|247:147,1,85.0000016212464,8|0,1:2|0:0,0:0:0:0: +46,62,108226,6,0,P|30:145|24:230,1,170.000003242493,2|8,0:2|1:2,0:0:0:0: +215,312,108560,1,0,0:0:0:0: +215,312,108671,2,0,P|191:272|152:252,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +62,140,108893,2,0,P|146:173|234:167,1,170.000003242493,10|0,1:2|0:0,0:0:0:0: +371,114,109226,2,0,L|376:22,2,85.0000016212464,2|8|0,0:2|1:2|0:0,0:0:0:0: +312,190,109560,5,0,0:0:0:0: +389,239,109671,1,0,0:0:0:0: +308,283,109782,1,8,1:2:0:0: +386,333,109893,1,0,0:0:0:0: +305,377,110004,2,0,P|267:367|226:331,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +77,199,110226,6,0,P|152:149|227:172,1,170.000003242493,8|0,1:2|0:0,0:0:0:0: +417,221,110560,1,0,0:0:0:0: +444,135,110671,1,8,1:2:0:0: +389,64,110782,1,0,0:0:0:0: +299,68,110893,2,0,P|210:71|156:9,1,170.000003242493,0|8,0:0|1:2,0:0:0:0: +62,140,111226,1,0,0:0:0:0: +62,140,111337,2,0,L|83:234,1,85.0000016212464 +262,296,111560,2,0,L|169:278,1,85.0000016212464,8|0,1:2|0:0,0:0:0:0: +349,153,111782,6,0,L|340:68,2,85.0000016212464,0|0|8,0:0|0:0|1:2,0:0:0:0: +236,329,112115,1,0,0:0:0:0: +292,267,112226,1,0,0:0:0:0: +375,253,112337,1,0,0:0:0:0: +449,291,112449,1,10,1:2:0:0: +378,336,112560,5,2,0:2:0:0: +375,253,112671,1,0,0:0:0:0: +372,170,112782,1,2,0:2:0:0: +369,87,112893,1,8,1:2:0:0: +442,125,113004,1,0,0:0:0:0: +348,178,113115,6,0,L|252:169,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +80,269,113337,2,0,P|120:276|175:256,1,85.0000016212464,10|0,1:2|0:0,0:0:0:0: +105,18,113560,2,0,P|86:55|87:103,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +260,255,113782,5,12,1:2:0:0: +261,212,113893,1,0,0:0:0:0: +262,170,114004,1,2,0:2:0:0: +414,61,114226,5,8,1:2:0:0: +340,101,114337,1,0,0:0:0:0: +256,109,114449,1,2,0:2:0:0: +176,81,114560,1,0,0:0:0:0: +105,18,114671,1,8,1:2:0:0: +84,118,114782,1,0,0:0:0:0: +63,218,114893,6,2,P|105:233|154:235,1,85.0000016212464,2|0,0:2|0:0,0:2:0:0: +369,328,115115,2,2,P|325:320|276:336,1,85.0000016212464,10|0,1:2|0:0,0:2:0:0: +256,109,115337,2,2,B|301:123|301:123|376:112,1,100,2|0,0:2|0:0,0:1:0:0: +488,273,115560,5,8,1:2:0:0: +488,273,115671,1,8,0:0:0:0: +488,273,115782,1,8,0:0:0:0: +429,83,116004,1,8,1:2:0:0: +429,83,116115,1,0,3:0:0:0: +429,83,116226,6,0,L|431:47,2,32.4999985122681,8|0|0,3:2|3:0|3:0,3:0:0:0: +381,198,116449,1,8,1:2:0:0: +381,198,116560,5,0,1:1:0:0: +187,140,116782,1,0,1:1:0:0: +102,323,117004,1,0,1:1:0:0: +58,34,117337,5,12,0:0:0:0: +380,124,128004,6,0,P|400:120|440:124,1,50,12|0,0:0|0:0,0:0:0:0: +396,256,128226,2,0,P|376:260|336:256,1,50,2|0,1:2|0:0,0:0:0:0: +104,200,128449,6,0,L|116:260,1,50,8|0,0:0|0:0,0:0:0:0: +172,104,128671,2,0,L|160:44,1,50,0|0,1:0|0:0,0:0:0:0: +272,184,128893,5,10,0:2:0:0: +280,208,129004,1,0,0:0:0:0: +280,232,129115,1,0,1:0:0:0: +272,256,129226,1,2,0:2:0:0: +264,280,129337,6,0,L|328:308,1,50,8|0,0:0|0:0,0:0:0:0: +464,260,129560,2,0,L|400:232,1,50,10|0,1:2|0:0,0:0:0:0: +317,113,129782,6,0,L|339:84,1,25,8|0,0:0|0:0,0:0:0:0: +286,96,129893,2,0,L|298:62,1,25 +252,92,130004,2,0,L|251:56,1,25,8|0,0:0|0:0,0:0:0:0: +218,99,130115,2,0,L|205:65,1,25 +189,117,130226,2,0,L|164:90,1,25,8|0,0:0|0:0,0:0:0:0: +167,145,130337,2,0,L|135:128,1,25,8|0,0:0|0:0,0:0:0:0: +156,178,130449,2,0,L|121:173,1,25,8|0,0:0|0:0,0:0:0:0: +158,212,130560,2,0,L|122:220,1,25,8|0,0:0|0:0,0:0:0:0: +171,244,130671,2,0,L|140:264,1,25,0|0,1:0|3:0,0:0:0:0: +194,270,130782,2,0,L|172:299,1,25,0|0,1:0|3:0,0:0:0:0: +225,287,130893,2,0,L|213:321,1,25,0|0,1:0|3:0,0:0:0:0: +259,291,131004,5,12,0:3:0:0: +259,291,131337,6,0,P|312:272|376:300,1,100,0|12,1:0|0:0,0:0:0:0: +456,68,132004,5,8,0:0:0:0: +340,184,132226,1,0,3:0:0:0: +284,20,132449,1,8,0:0:0:0: +156,160,132671,5,0,0:0:0:0: +156,160,132782,2,0,P|216:152|256:160,1,100,0|0,3:0|0:0,0:0:0:0: +92,235,133115,2,0,L|20:251,1,50,0|0,3:0|0:0,0:0:0:0: +281,303,133337,5,8,0:0:0:0: +471,213,133560,1,0,3:0:0:0: +281,303,133782,1,8,0:0:0:0: +363,84,134004,1,0,3:0:0:0: +241,162,134226,6,0,L|222:94,1,50,8|0,0:0|0:0,0:0:0:0: +164,204,134449,1,0,0:0:0:0: +164,204,134560,2,0,P|112:196|51:220,1,100,0|0,3:0|3:0,0:0:0:0: +281,303,134893,2,0,P|333:311|394:287,1,100,0|8,0:0|0:0,0:0:0:0: +500,184,135337,5,0,3:0:0:0: +465,11,135560,1,8,0:0:0:0: +294,44,135782,1,0,3:0:0:0: +327,217,136004,1,8,0:0:0:0: +408,112,136226,5,0,0:0:0:0: +408,112,136337,1,0,3:0:0:0: +364,316,136560,1,0,0:0:0:0: +364,316,136671,1,0,3:0:0:0: +168,292,136893,5,8,0:0:0:0: +48,212,137115,1,0,3:0:0:0: +188,16,137337,1,8,0:0:0:0: +176,156,137560,1,0,3:0:0:0: +344,292,137782,6,0,P|408:288|464:252,1,100,8|0,0:0|0:0,0:0:0:0: +388,176,138115,2,0,P|408:124|400:68,1,100,0|0,3:0|3:0,0:0:0:0: +168,192,138449,1,0,0:0:0:0: +168,192,138560,1,0,0:0:0:0: +168,192,138671,2,0,L|280:204,1,100,10|0,0:0|3:0,0:0:0:0: +464,168,139115,5,8,0:0:0:0: +344,292,139337,1,0,3:0:0:0: +332,24,139560,1,8,0:0:0:0: +288,368,139782,6,0,P|344:384|404:368,1,100,2|8,0:0|0:0,0:0:0:0: +224,16,140226,2,0,P|168:0|108:16,1,100,2|8,0:0|0:0,0:0:0:0: +80,224,140671,2,0,P|64:280|80:340,1,100,2|8,0:0|0:0,0:0:0:0: +400,152,141115,5,0,0:0:0:0: +300,268,141337,1,8,0:0:0:0: +460,80,141560,2,0,P|404:56|336:88,1,100,2|8,0:0|0:0,0:0:0:0: +332,224,142004,2,0,L|352:360,1,100,0|8,0:0|0:0,0:0:0:0: +184,88,142449,6,0,L|178:38,1,50 +204,136,142671,1,8,0:0:0:0: +212,184,142782,1,0,3:0:0:0: +196,232,142893,1,0,3:0:0:0: +152,256,143004,1,0,0:0:0:0: +104,236,143115,1,8,0:0:0:0: +56,252,143226,1,0,3:0:0:0: +32,296,143337,1,0,3:0:0:0: +52,340,143449,1,0,3:0:0:0: +92,372,143560,1,8,0:0:0:0: +140,352,143671,1,0,3:0:0:0: +188,336,143782,1,8,0:0:0:0: +236,348,143893,1,0,3:0:0:0: +280,368,144004,5,8,0:0:0:0: +448,260,144449,6,0,L|416:248,1,25,8|0,0:0|3:0,0:0:0:0: +424,251,144560,2,0,L|392:240,1,25,0|0,3:0|3:0,0:0:0:0: +400,243,144671,2,0,L|360:228,1,25,8|0,0:0|3:0,0:0:0:0: +377,234,144782,2,0,L|348:224,1,25,0|0,3:0|3:0,0:0:0:0: +316,200,144893,6,0,P|308:176|308:132,1,50,8|8,0:0|0:0,0:0:0:0: +404,40,145115,2,0,P|412:64|412:108,1,50,8|8,0:0|3:2,0:0:0:0: +252,264,145337,5,8,0:0:0:0: +252,264,145449,1,8,3:2:0:0: +252,264,145560,2,0,P|196:256|136:272,1,100,8|12,0:0|0:0,0:0:0:0: +34,77,146226,5,8,0:0:0:0: +54,230,146449,1,0,3:0:0:0: +28,27,146671,2,0,L|36:105,1,50,8|0,0:0|0:0,0:0:0:0: +54,230,146893,1,0,0:0:0:0: +54,230,147004,2,0,L|62:152,1,50,0|8,3:0|0:0,0:0:0:0: +234,135,147226,1,0,0:0:0:0: +234,135,147337,2,0,L|347:149,1,100,0|8,3:0|0:0,0:0:0:0: +156,190,147782,5,0,3:0:0:0: +333,147,148004,1,8,0:0:0:0: +133,120,148226,1,0,3:0:0:0: +358,65,148449,2,0,L|261:89,1,100,8|0,0:0|0:0,0:0:0:0: +333,147,148782,2,0,L|446:161,1,100,0|0,3:0|3:0,0:0:0:0: +321,275,149115,2,0,L|302:214,1,50,0|0,1:0|0:0,0:0:0:0: +462,165,149337,6,0,L|428:273,1,100,8|0,0:0|3:0,0:0:0:0: +393,83,149782,1,8,0:0:0:0: +431,260,150004,1,0,3:0:0:0: +221,288,150226,2,0,L|331:273,1,100,8|0,0:0|0:0,0:0:0:0: +190,296,150560,2,0,L|171:235,1,50,0|8,3:0|0:0,0:0:0:0: +334,226,150782,1,0,0:0:0:0: +334,226,150893,2,0,L|315:287,1,50,0|0,3:0|0:0,0:0:0:0: +238,135,151115,6,0,L|257:196,1,50,8|0,0:0|0:0,0:0:0:0: +190,296,151337,2,0,L|209:357,1,50,0|0,3:0|0:0,0:0:0:0: +118,72,151560,2,0,L|137:133,1,50,8|0,0:0|0:0,0:0:0:0: +24,221,151782,2,0,L|43:282,1,50,0|0,3:0|0:0,0:0:0:0: +242,367,152004,5,8,0:0:0:0: +276,362,152115,1,0,3:0:0:0: +311,357,152226,1,8,0:0:0:0: +346,353,152337,1,0,3:0:0:0: +386,338,152449,5,8,1:2:0:0: +337,327,152560,1,0,3:0:0:0: +288,317,152671,1,8,1:2:0:0: +239,306,152782,1,0,3:0:0:0: +190,296,152893,1,10,1:2:0:0: +190,296,153337,5,8,0:0:0:0: +241,77,153560,1,0,3:0:0:0: +200,262,153782,1,8,0:0:0:0: +447,200,154004,2,0,L|336:181,1,100,2|8,0:0|0:0,0:0:0:0: +97,119,154449,2,0,L|159:109,1,50,2|0,0:0|0:0,0:0:0:0: +348,183,154671,5,10,0:2:0:0: +151,210,154893,2,0,L|146:111,1,100,2|8,0:0|0:0,0:0:0:0: +353,83,155337,1,0,3:0:0:0: +350,132,155449,1,0,3:0:0:0: +347,182,155560,1,8,0:0:0:0: +31,78,155782,2,0,B|174:62|174:62|145:110,1,200,2|4,0:0|0:1,0:0:0:0: +113,280,156449,6,0,L|111:229,1,50,12|0,0:2|0:0,0:0:0:0: +145,110,156671,1,2,0:0:0:0: +108,312,156893,2,0,L|42:303,1,50,10|0,0:2|1:0,0:0:0:0: +211,208,157115,2,0,L|277:217,1,50,0|0,3:0|1:0,0:0:0:0: +414,288,157337,6,0,L|387:290,3,25,10|0|0|0,0:2|3:0|3:0|3:0,0:0:0:0: +290,340,157560,2,0,L|316:342,3,25,0|0|2|0,1:0|3:0|3:2|3:0,0:0:0:0: +414,288,157782,6,0,L|421:221,1,50,8|0,0:0|3:0,0:0:0:0: +315,130,158004,2,0,L|332:193,1,50,8|0,0:0|3:0,0:0:0:0: +492,100,158226,2,0,L|426:91,1,50,12|2,1:2|3:2,0:0:0:0: +214,158,158449,2,0,L|202:97,1,50,10|0,1:2|3:0,0:0:0:0: +342,13,158671,1,8,1:2:0:0: +342,13,158782,1,8,1:2:0:0: +342,13,158893,2,0,L|339:46,2,25,8|0|8,1:2|3:0|1:2,0:0:0:0: +332,156,159115,5,0,1:0:0:0: +332,156,159171,1,0,3:0:0:0: +332,156,159226,2,0,P|352:156|380:166,1,50,8|0,1:2|0:0,0:0:0:0: +456,260,159449,2,0,P|456:280|446:308,1,50,8|2,1:2|0:0,0:0:0:0: +340,368,159671,1,8,1:2:0:0: +340,368,159782,2,0,P|320:368|292:358,1,50 +44,272,160004,6,0,P|152:268|212:196,1,170.000003242493,12|0,0:2|0:0,0:0:0:0: +324,28,160337,1,0,0:0:0:0: +264,100,160449,1,8,0:2:0:0: +312,180,160560,1,0,0:0:0:0: +404,164,160671,6,0,P|400:248|348:320,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +184,328,161004,1,0,0:0:0:0: +96,352,161115,1,0,0:0:0:0: +32,280,161226,1,0,0:0:0:0: +56,192,161337,6,0,L|48:104,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +184,24,161560,6,0,P|228:76|344:32,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +476,176,161893,1,0,0:0:0:0: +384,196,162004,1,0,0:0:0:0: +356,280,162115,1,0,0:0:0:0: +416,352,162226,6,0,L|240:360,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +64,312,162560,1,2,2:0:0:0: +128,244,162671,1,8,0:2:0:0: +152,156,162782,1,0,0:0:0:0: +96,80,162893,6,0,P|120:40|168:12,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +361,18,163115,2,0,P|395:43|416:80,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +256,224,163337,2,0,L|256:124,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +356,280,163560,5,8,0:2:0:0: +356,280,163671,1,0,0:0:0:0: +356,280,163782,1,0,0:0:0:0: +356,280,163893,1,0,0:0:0:0: +356,280,164004,2,0,P|268:268|212:348,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +124,344,164337,1,0,0:0:0:0: +92,256,164449,2,0,P|140:180|96:92,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +180,184,164782,5,0,0:0:0:0: +180,184,164893,2,0,L|284:176,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +500,88,165115,6,0,P|418:70|345:110,1,170.000003242493,2|8,0:2|0:2,0:0:0:0: +320,236,165449,1,0,0:0:0:0: +320,236,165560,2,0,L|332:336,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +432,156,165782,6,0,P|408:56|320:16,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +216,64,166115,5,2,0:2:0:0: +216,64,166226,2,0,L|126:70,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +143,208,166449,2,0,P|122:246|132:311,1,85.0000016212464 +312,291,166671,2,0,P|273:270|208:280,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +379,185,166893,2,0,P|417:205|482:195,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +456,64,167115,6,0,P|344:44|284:84,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +112,148,167449,1,0,0:0:0:0: +160,228,167560,1,8,0:2:0:0: +248,256,167671,1,0,0:0:0:0: +336,228,167782,6,0,P|432:216|480:272,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +392,308,168115,5,0,0:0:0:0: +336,228,168226,2,0,L|332:124,1,85.0000016212464 +448,32,168449,2,0,L|452:136,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +392,308,168671,6,0,P|324:272|216:320,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +156,340,169004,1,0,0:0:0:0: +88,272,169115,2,0,L|124:176,1,85.0000016212464 +88,56,169337,6,0,P|188:100|284:44,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +416,28,169671,1,2,0:2:0:0: +360,104,169782,1,8,0:2:0:0: +352,196,169893,1,0,0:0:0:0: +396,280,170004,6,0,P|400:328|432:364,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +272,296,170226,2,0,P|268:248|236:212,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +68,288,170449,2,0,P|116:276|168:288,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +324,148,170671,6,0,P|256:168|244:56,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +0,244,171004,2,0,L|180:264,1,170.000003242493 +460,240,171337,6,0,P|464:280|456:356,1,85.0000016212464 +336,284,171560,2,0,P|332:244|340:168,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +388,40,171782,2,0,P|412:80|416:132,1,85.0000016212464 +204,80,172004,2,0,P|252:80|308:96,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +208,192,172226,2,0,P|160:192|104:176,1,85.0000016212464 +256,48,172449,6,0,P|312:56|352:68,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +400,192,172671,2,0,P|392:248|380:288,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +256,336,172893,2,0,P|200:328|160:316,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +112,192,173115,2,0,P|120:136|132:96,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +400,192,173337,6,0,P|392:248|380:288,1,85.0000016212464,10|8,0:2|0:2,0:0:0:0: +256,48,173560,2,0,P|312:56|352:68,1,85.0000016212464,0|8,0:0|0:2,0:0:0:0: +172,320,173782,1,2,0:2:0:0: +117,245,173893,1,0,0:0:0:0: +116,153,174004,1,8,0:2:0:0: +169,78,174115,1,8,0:2:0:0: +256,48,174226,6,0,P|334:91|329:184,1,170.000003242493,12|0,0:2|0:0,0:0:0:0: +168,268,174560,1,0,0:0:0:0: +238,207,174671,1,8,0:2:0:0: +329,194,174782,1,0,0:0:0:0: +413,232,174893,2,0,L|426:331,2,85.0000016212464,0|0|8,0:0|0:0|0:2,0:0:0:0: +344,150,175226,1,0,0:0:0:0: +344,150,175337,2,0,L|261:134,1,85.0000016212464 +478,62,175560,2,0,L|466:164,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +322,328,175782,6,0,P|253:282|149:306,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +50,366,176115,1,0,0:0:0:0: +91,283,176226,1,0,0:0:0:0: +81,191,176337,1,0,0:0:0:0: +24,119,176449,2,0,P|93:165|197:141,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +327,79,176782,2,0,L|306:252,1,170.000003242493,2|0,0:2|0:0,0:0:0:0: +125,323,177115,6,0,P|161:302|203:298,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +389,256,177337,2,0,P|347:259|297:243,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +180,72,177560,2,0,P|221:88|261:137,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +66,257,177782,1,8,0:2:0:0: +64,235,177893,1,0,0:0:0:0: +63,214,178004,1,0,0:0:0:0: +62,193,178115,1,0,0:0:0:0: +61,172,178226,6,0,P|131:162|136:254,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +4,96,178560,1,0,0:0:0:0: +81,61,178671,1,8,0:2:0:0: +164,73,178782,1,0,0:0:0:0: +227,128,178893,1,0,0:0:0:0: +251,209,179004,1,0,0:0:0:0: +228,289,179115,2,0,P|213:252|218:200,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +401,62,179337,6,0,P|337:115|250:106,1,170.000003242493,2|8,0:2|0:2,0:0:0:0: +93,212,179671,1,0,0:0:0:0: +93,212,179782,2,0,P|143:218|184:248,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +334,366,180004,2,0,P|339:312|345:185,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +311,33,180337,2,0,P|265:16|223:22,2,85.0000016212464,2|8|0,2:0|0:2|0:0,0:0:0:0: +289,212,180671,5,0,0:0:0:0: +196,198,180782,1,0,0:0:0:0: +255,124,180893,1,8,0:2:0:0: +301,221,181004,5,0,0:0:0:0: +181,203,181115,1,8,0:2:0:0: +257,108,181226,1,0,0:0:0:0: +372,147,181337,6,0,P|407:214|372:301,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +163,238,181671,1,0,0:0:0:0: +74,225,181782,1,8,0:2:0:0: +18,294,181893,1,0,0:0:0:0: +50,378,182004,2,0,P|161:346|236:375,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +355,315,182337,1,0,0:0:0:0: +355,315,182449,2,0,P|346:273|355:211,1,85.0000016212464 +423,86,182671,2,0,P|432:128|423:190,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +214,113,182893,6,0,L|29:142,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +181,224,183226,1,0,0:0:0:0: +264,237,183337,1,0,0:0:0:0: +348,250,183449,1,0,0:0:0:0: +431,262,183560,2,0,P|446:302|448:344,2,85.0000016212464,10|2|0,0:2|0:2|0:0,0:0:0:0: +282,68,183893,2,0,P|211:119|219:199,1,170.000003242493,2|0,0:2|0:0,0:0:0:0: +391,175,184226,6,0,L|307:161,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +132,298,184449,2,0,L|186:232,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +154,12,184671,2,0,L|183:91,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +395,82,184893,5,12,0:2:0:0: +473,129,185004,1,0,0:0:0:0: +391,175,185115,2,0,P|341:179|286:160,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +105,112,185337,5,8,0:2:0:0: +26,158,185449,1,0,0:0:0:0: +108,204,185560,2,0,P|157:207|212:188,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +314,35,185782,5,8,0:2:0:0: +230,71,185893,1,0,0:0:0:0: +188,153,186004,1,2,0:2:0:0: +206,243,186115,1,0,0:0:0:0: +277,300,186226,2,0,P|319:313|381:310,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +469,143,186449,2,2,P|423:143|383:163,1,85.0000016212464,2|0,0:2|0:0,0:1:0:0: +180,96,186671,5,8,0:2:0:0: +180,96,186782,1,8,0:0:0:0: +180,96,186893,1,8,0:0:0:0: +333,236,187115,2,0,L|346:127,1,85.0000016212464,8|0,0:2|3:0,0:0:0:0: +404,53,187337,2,0,L|398:10,2,42.5000008106232,8|0|0,3:2|3:0|3:0,0:0:0:0: +426,195,187560,1,8,0:2:0:0: +426,195,187671,5,0,0:0:0:0: +240,159,187893,1,0,0:0:0:0: +350,339,188115,1,0,0:0:0:0: +70,296,188449,5,12,0:0:0:0: +432,192,195560,5,4,0:0:0:0: +432,168,195671,1,0,3:0:0:0: +424,144,195782,1,0,3:0:0:0: +416,120,195893,1,0,3:0:0:0: +400,96,196004,1,0,3:0:0:0: +384,80,196115,1,0,3:0:0:0: +368,64,196226,1,0,3:0:0:0: +344,48,196337,1,0,3:0:0:0: +320,40,196449,1,0,3:0:0:0: +296,40,196560,1,0,3:0:0:0: +272,40,196671,1,0,3:0:0:0: +248,48,196782,1,0,3:0:0:0: +224,64,196893,1,0,3:0:0:0: +208,80,197004,1,0,3:0:0:0: +192,104,197115,1,2,3:2:0:0: +184,128,197226,1,0,3:0:0:0: +176,152,197337,5,0,3:0:0:0: +160,168,197449,1,0,3:0:0:0: +136,184,197560,1,0,3:0:0:0: +112,192,197671,1,0,3:0:0:0: +96,208,197782,1,0,3:0:0:0: +80,232,197893,1,2,3:2:0:0: +80,256,198004,1,0,3:0:0:0: +88,280,198115,1,0,3:0:0:0: +112,296,198226,1,2,3:2:0:0: +136,304,198337,1,0,3:0:0:0: +160,304,198449,1,0,3:0:0:0: +184,296,198560,1,2,3:2:0:0: +200,280,198671,1,0,3:0:0:0: +224,264,198782,1,0,3:0:0:0: +248,256,198893,1,2,3:2:0:0: +272,264,199004,1,2,3:2:0:0: +296,280,199115,5,0,3:0:0:0: +320,288,199226,1,0,3:0:0:0: +344,288,199337,1,2,3:2:0:0: +368,280,199449,1,0,3:0:0:0: +392,264,199560,1,0,3:0:0:0: +408,248,199671,1,0,3:0:0:0: +424,224,199782,1,0,3:0:0:0: +432,200,199893,1,0,3:0:0:0: +432,176,200004,1,0,3:0:0:0: +424,152,200115,1,0,3:0:0:0: +416,128,200226,1,2,3:2:0:0: +400,104,200337,1,0,3:0:0:0: +384,88,200449,1,0,3:0:0:0: +360,72,200560,1,0,3:0:0:0: +336,64,200671,1,2,3:2:0:0: +312,64,200782,1,0,3:0:0:0: +288,72,200893,6,2,L|280:32,1,25,0|0,3:0|3:0,0:1:0:0: +264,80,201004,2,2,L|256:40,1,25,0|0,3:0|3:0,0:1:0:0: +240,88,201115,2,0,L|232:48,1,25,0|0,3:0|3:0,0:0:0:0: +216,96,201226,2,0,L|208:56,1,25,0|0,3:0|3:0,0:0:0:0: +192,104,201337,2,2,L|184:64,1,25,8|0,3:2|3:0,0:1:0:0: +168,112,201449,2,0,L|160:72,1,25,0|0,3:0|3:0,0:0:0:0: +144,120,201560,2,0,L|136:80,1,25,8|0,3:2|3:0,0:0:0:0: +120,128,201671,2,2,L|115:103,1,25,0|0,3:0|3:0,0:1:0:0: +96,136,201782,6,0,L|77:100,1,25,8|0,3:2|3:0,0:0:0:0: +57,173,201893,2,0,L|23:151,1,25,8|0,3:2|3:0,0:0:0:0: +41,224,202004,2,2,L|1:222,1,25,0|0,3:0|3:0,0:1:0:0: +53,277,202115,2,0,L|17:295,1,25,8|0,3:2|3:0,0:0:0:0: +90,316,202226,2,0,L|68:349,1,25,8|0,3:2|3:0,0:0:0:0: +141,332,202337,2,2,L|139:371,1,25,0|0,3:0|3:0,0:1:0:0: +194,320,202449,2,0,L|212:355,1,25,8|0,3:2|3:0,0:0:0:0: +233,283,202560,2,0,L|266:304,1,25,8|0,3:2|3:0,0:0:0:0: +249,231,202671,6,0,L|432:216,1,150.000005722046,12|0,0:0|0:0,0:0:0:0: +496,104,203115,5,8,0:0:0:0: +400,328,203337,1,0,0:0:0:0: +400,328,203449,1,0,0:0:0:0: +400,328,203560,2,0,P|352:296|336:256,1,100,10|0,0:0|0:0,0:0:0:0: +296,104,203893,5,0,0:0:0:0: +296,104,204004,2,0,L|352:112,1,50,8|0,0:0|0:0,0:0:0:0: +160,168,204226,1,0,0:0:0:0: +160,168,204337,1,0,0:0:0:0: +160,168,204449,2,0,P|112:160|56:192,1,100,10|2,0:0|0:0,0:0:0:0: +136,320,204893,5,8,0:0:0:0: +304,224,205115,1,0,0:0:0:0: +328,224,205226,1,0,0:0:0:0: +352,224,205337,1,10,0:0:0:0: +464,144,205560,5,0,0:0:0:0: +464,144,205671,1,0,0:0:0:0: +464,144,205782,2,2,P|480:112|480:96,1,50,8|0,0:0|0:0,0:1:0:0: +400,320,206004,2,2,P|384:352|384:368,1,50,0|0,3:0|0:0,0:1:0:0: +304,224,206226,6,0,P|248:200|184:224,1,100,10|2,0:0|0:0,0:0:0:0: +24,296,206671,1,8,0:0:0:0: +160,104,206893,1,0,0:0:0:0: +248,304,207115,2,0,P|304:328|368:304,1,100,10|0,0:0|0:0,0:0:0:0: +340,321,207782,6,0,L|316:193,1,100,2|8,0:0|0:0,0:0:0:0: +376,72,208226,1,2,0:0:0:0: +144,152,208449,1,8,0:0:0:0: +256,336,208671,1,0,0:0:0:0: +272,40,208893,5,10,0:0:0:0: +112,304,209115,1,8,0:0:0:0: +440,224,209337,6,2,P|480:224|512:232,1,50,8|0,0:0|3:0,0:1:0:0: +440,320,209560,2,2,P|400:320|368:312,1,50,8|0,0:0|3:0,0:1:0:0: +248,232,209782,5,8,3:2:0:0: +216,216,209893,1,0,3:0:0:0: +184,208,210004,1,2,3:0:0:0: +152,208,210115,1,0,0:0:0:0: +121,223,210226,6,2,L|109:132,1,50,8|0,3:2|3:0,0:1:0:0: +53,84,210449,1,2,3:0:0:0: +53,84,210560,2,0,P|108:67|180:81,1,100,0|0,0:0|3:0,0:0:0:0: +339,150,210893,1,0,3:0:0:0: +339,150,211004,1,2,3:0:0:0: +339,150,211115,2,0,L|202:141,1,100,8|2,0:0|3:0,0:0:0:0: +406,107,211449,5,0,3:0:0:0: +406,107,211560,2,0,P|380:155|400:244,1,150,8|0,0:2|0:0,0:0:0:0: +461,315,212004,6,0,L|533:308,1,50,8|0,3:2|3:0,0:0:0:0: +308,351,212226,1,2,3:0:0:0: +308,351,212337,2,0,P|255:332|177:351,1,100,0|0,0:0|3:0,0:0:0:0: +64,352,212671,1,0,3:0:0:0: +64,352,212782,1,2,3:0:0:0: +64,352,212893,2,0,L|96:240,1,100,8|4,0:0|3:2,0:0:0:0: +56,96,213226,5,0,0:0:0:0: +56,96,213337,2,0,P|113:140|185:126,1,150,12|0,0:0|0:0,0:0:0:0: +232,104,213782,1,8,0:0:0:0: +280,80,213893,1,0,0:0:0:0: +328,72,214004,1,0,0:0:0:0: +376,80,214115,1,0,0:0:0:0: +416,104,214226,1,10,0:0:0:0: +448,144,214337,1,0,0:0:0:0: +456,192,214449,1,2,0:0:0:0: +448,240,214560,1,0,0:0:0:0: +416,280,214671,1,8,0:0:0:0: +376,304,214782,1,0,0:0:0:0: +328,312,214893,1,10,0:0:0:0: +280,304,215004,1,0,0:0:0:0: +240,280,215115,6,0,L|192:280,1,25,8|0,3:2|3:0,0:0:0:0: +215,280,215226,2,0,L|176:280,1,25,0|0,3:0|3:0,0:0:0:0: +190,280,215337,2,0,L|160:280,1,25,2|0,3:2|3:0,0:0:0:0: +165,280,215449,2,0,L|120:280,1,25,0|0,3:0|3:0,0:0:0:0: +112,280,215560,5,8,0:0:0:0: +32,224,215671,1,8,0:0:0:0: +120,176,215782,2,0,L|120:128,2,25,8|8|8,0:0|0:0|0:0,0:0:0:0: +240,64,216115,5,0,1:0:0:0: +344,224,216337,1,0,1:0:0:0: +448,32,216560,1,0,1:0:0:0: +145,84,216893,5,12,0:0:0:0: +198,332,217115,2,0,L|219:193,1,119.999996337891,2|8,0:0|0:0,0:0:0:0: +308,165,217449,1,0,0:0:0:0: +308,165,217560,1,0,0:0:0:0: +81,244,217782,1,8,0:0:0:0: +76,214,217893,1,0,0:0:0:0: +72,184,218004,1,2,0:0:0:0: +67,155,218115,1,0,0:0:0:0: +63,125,218226,1,8,0:0:0:0: +200,49,218449,6,0,P|267:85|382:74,1,179.999994506836,2|0,0:0|0:0,0:0:0:0: +422,36,218893,2,0,P|355:108|357:200,1,179.999994506836,2|0,0:0|0:0,0:0:0:0: +512,151,219337,2,0,L|452:142,1,59.9999981689454,2|0,0:0|0:0,0:0:0:0: +318,194,219560,6,2,L|263:202,5,29.9999990844727,8|0|0|0|0|0,0:0|0:0|0:0|0:0|0:0|0:0,0:1:0:0: +424,218,219893,1,0,0:0:0:0: +426,247,220004,1,8,0:0:0:0: +429,276,220115,1,0,0:0:0:0: +432,305,220226,6,0,B|377:333|377:333|325:317|325:317|272:340|201:317,1,239.999992675781,2|0,0:0|0:0,0:0:0:0: +97,166,220893,1,8,0:0:0:0: +269,215,221115,2,0,L|278:135,1,59.9999981689454,2|0,0:0|0:0,0:0:0:0: +159,41,221337,1,8,3:2:0:0: +163,78,221449,1,0,3:0:0:0: +167,115,221560,1,0,3:0:0:0: +171,152,221671,1,2,3:2:0:0: +176,189,221782,2,0,L|167:270,1,59.9999981689454,8|0,0:0|0:0,0:0:0:0: +24,352,222004,6,0,L|203:335,1,179.999994506836,2|0,0:0|0:0,0:0:0:0: +346,297,222449,2,0,L|166:280,1,179.999994506836,2|0,0:0|3:0,0:0:0:0: +68,237,222893,1,2,3:2:0:0: +103,233,223004,1,0,3:0:0:0: +139,230,223115,6,2,L|216:238,1,59.9999981689454,8|0,0:0|0:0,0:1:0:0: +377,324,223337,2,2,L|419:260,1,59.9999981689454,8|0,0:0|0:0,0:1:0:0: +278,145,223560,2,0,L|290:227,1,59.9999981689454,8|0,0:0|0:0,0:0:0:0: +452,83,223782,2,0,L|375:91,1,59.9999981689454,8|0,0:0|0:0,0:0:0:0: +186,134,224004,5,8,3:2:0:0: +146,131,224115,1,0,3:0:0:0: +109,116,224226,1,2,3:0:0:0: +77,92,224337,1,0,0:0:0:0: +15,29,224449,5,8,3:2:0:0: +11,78,224560,1,0,3:0:0:0: +8,128,224671,1,2,3:0:0:0: +4,178,224782,1,0,0:0:0:0: +89,198,224893,5,8,3:2:0:0: +96,237,225004,1,0,3:0:0:0: +103,276,225115,1,0,3:0:0:0: +111,315,225226,1,2,3:0:0:0: +206,281,225337,5,8,0:0:0:0: +245,287,225449,1,0,3:0:0:0: +285,293,225560,1,2,3:0:0:0: +324,299,225671,1,0,0:0:0:0: +408,249,225782,5,8,3:2:0:0: +373,213,225893,1,0,3:0:0:0: +326,200,226004,1,0,3:0:0:0: +278,213,226115,1,0,3:0:0:0: +206,281,226226,5,8,3:2:0:0: +182,237,226337,1,0,3:0:0:0: +182,188,226449,1,0,3:0:0:0: +207,144,226560,1,0,0:0:0:0: +306,164,226671,5,8,3:2:0:0: +354,156,226782,1,0,3:0:0:0: +392,124,226893,1,0,3:0:0:0: +407,77,227004,1,2,3:0:0:0: +301,55,227115,5,8,0:0:0:0: +303,109,227226,1,2,3:0:0:0: +306,164,227337,1,0,3:0:0:0: +308,219,227449,1,0,0:0:0:0: +360,304,227560,5,8,3:2:0:0: +301,297,227671,1,0,3:2:0:0: +246,321,227782,2,0,P|216:329|176:319,1,59.9999981689454,2|0,3:2|0:0,0:0:0:0: +49,207,228004,2,0,L|56:281,1,59.9999981689454,8|0,3:2|1:0,0:0:0:0: +208,185,228226,2,0,L|215:111,1,59.9999981689454,2|0,3:2|1:0,0:0:0:0: +44,29,228449,5,10,3:2:0:0: +40,58,228560,1,0,3:0:0:0: +36,88,228671,1,0,3:0:0:0: +32,117,228782,1,8,0:0:0:0: +123,105,228893,1,8,3:2:0:0: +212,92,229004,6,0,L|254:72,1,29.9999990844727,0|0,3:0|3:0,3:0:0:0: +260,125,229115,2,0,L|300:123,1,29.9999990844727,10|0,3:2|3:0,3:0:0:0: +282,180,229226,2,0,L|319:193,1,29.9999990844727,0|0,3:0|3:0,3:0:0:0: +269,237,229337,5,8,3:2:0:0: +219,237,229449,1,0,3:0:0:0: +174,257,229560,1,2,3:2:0:0: +142,295,229671,1,0,0:0:0:0: +245,353,229782,5,8,3:2:0:0: +293,340,229893,1,8,3:2:0:0: +342,349,230004,2,0,L|377:334,1,29.9999990844727,2|0,3:2|3:0,0:0:0:0: +383,376,230115,1,0,3:0:0:0: +467,284,230226,5,0,1:0:0:0: +464,234,230337,1,8,1:2:0:0: +461,184,230449,1,0,0:0:0:0: +458,134,230560,1,8,1:2:0:0: +310,42,230671,5,0,3:0:0:0: +300,90,230782,1,8,1:2:0:0: +322,133,230893,1,0,3:0:0:0: +367,154,231004,1,0,0:0:0:0: +458,134,231115,6,0,L|465:242,1,89.9999972534181,12|0,0:0|0:0,0:0:0:0: +369,307,231782,2,0,P|310:301|248:334,1,119.999996337891 +137,289,232449,1,0,0:0:0:0: +137,289,232671,2,0,B|124:242|124:242|140:160,1,119.999996337891,2|0,0:0|0:0,0:0:0:0: +159,135,233337,2,0,P|221:159|296:148,1,119.999996337891,2|0,0:0|0:0,0:0:0:0: +375,81,234004,1,2,0:0:0:0: +389,206,234226,1,2,0:0:0:0: +273,155,234449,1,2,0:0:0:0: +143,320,235115,6,0,P|80:311|26:342,2,119.999996337891 +253,363,236226,2,0,B|274:307|240:278|261:239,1,119.999996337891,2|0,0:0|0:0,0:0:0:0: +303,208,236893,2,0,L|164:197,1,119.999996337891,2|0,0:0|0:0,0:0:0:0: +76,264,237560,1,0,0:0:0:0: +48,170,237782,1,2,0:0:0:0: +99,88,238004,1,10,0:0:0:0: +195,72,238226,6,0,P|245:87|301:87,1,89.9999972534181,4|0,0:0|0:0,0:0:0:0: +430,40,238893,2,0,P|441:102|450:172,1,119.999996337891 +443,280,239560,1,0,0:0:0:0: +340,214,239782,2,0,P|325:155|343:98,1,119.999996337891,2|0,0:0|0:0,0:0:0:0: +430,40,240449,2,0,P|384:47|286:27,1,119.999996337891,2|0,0:0|0:0,0:0:0:0: +195,72,241115,1,0,0:0:0:0: +181,191,241337,1,0,0:0:0:0: +291,143,241560,1,2,0:0:0:0: +195,72,241782,6,0,P|143:75|97:107,1,89.9999972534181,2|0,0:0|0:0,0:0:0:0: +90,271,242449,2,0,P|196:312|291:241,1,239.999992675781,2|2,0:0|0:0,0:0:0:0: +289,243,243449,2,0,P|328:264|373:264,1,89.9999972534181,2|0,0:0|3:0,0:0:0:0: +465,186,244004,1,0,3:0:0:0: +409,80,244226,5,0,3:0:0:0: +379,82,244337,1,0,3:0:0:0: +349,84,244449,1,10,0:0:0:0: +321,87,244560,2,0,L|271:91,1,29.9999990844727,8|0,1:2|0:0,0:0:0:0: +160,48,244782,5,8,1:2:0:0: +152,72,244893,1,10,0:0:0:0: +144,96,245004,1,8,0:0:0:0: +136,120,245115,1,8,0:0:0:0: +128,144,245226,1,8,0:0:0:0: +72,184,245337,6,0,L|212:196,1,100,12|0,0:0|0:0,0:0:0:0: +208,232,245671,2,0,P|256:241|299:217,1,100 +320,176,246004,2,0,L|312:76,1,100,0|8,0:0|0:0,0:0:0:0: +200,136,246449,5,0,0:0:0:0: +304,256,246671,1,8,0:0:0:0: +400,120,246893,2,0,L|536:112,1,100,2|8,0:0|0:0,0:0:0:0: +336,176,247337,5,0,0:0:0:0: +336,176,247449,1,0,0:0:0:0: +336,176,247560,2,0,P|288:152|216:184,1,100,10|0,0:0|0:0,0:0:0:0: +244,160,248004,1,8,0:0:0:0: +80,216,248226,5,2,0:0:0:0: +280,256,248449,1,10,0:0:0:0: +192,80,248671,2,0,L|192:200,1,100,2|8,0:0|0:0,0:0:0:0: +280,256,249115,1,0,0:0:0:0: +392,208,249337,6,0,P|408:256|368:328,1,100,8|0,0:0|0:0,0:0:0:0: +280,256,249782,2,0,L|144:264,1,100,8|0,0:0|0:0,0:0:0:0: +72,184,250226,1,8,0:0:0:0: +120,80,250449,2,0,P|161:99|185:179,1,100,2|8,0:0|0:0,0:0:0:0: +136,280,250893,5,0,0:0:0:0: +136,280,251115,2,0,L|352:272,1,200,10|8,0:0|0:0,0:0:0:0: +416,152,251782,5,0,0:0:0:0: +488,80,252004,1,10,0:0:0:0: +416,8,252226,1,2,0:0:0:0: +344,80,252449,6,0,L|232:88,1,100,10|0,0:0|0:0,0:0:0:0: +192,48,252782,2,0,P|200:72|200:168,1,100 +152,176,253115,2,0,L|288:192,1,100,0|8,0:0|0:0,0:0:0:0: +448,176,253560,5,0,0:0:0:0: +344,304,253782,1,8,0:0:0:0: +272,152,254004,2,0,L|272:16,1,100,2|8,0:0|0:0,0:0:0:0: +328,208,254449,5,0,0:0:0:0: +328,208,254671,2,0,P|264:200|208:216,1,100,10|0,0:0|0:0,0:0:0:0: +72,288,255115,1,8,0:0:0:0: +136,168,255337,6,0,L|112:104,1,50,2|0,0:0|0:0,0:0:0:0: +216,32,255560,2,0,L|232:88,1,50,10|0,0:0|0:0,0:0:0:0: +328,160,255782,2,0,L|347:114,1,50,2|0,0:0|0:0,0:0:0:0: +424,224,256004,6,0,L|451:233,1,25,10|0,2:2|2:0,2:0:0:0: +390,272,256115,2,0,L|407:295,1,25,0|0,2:0|2:0,2:0:0:0: +333,291,256226,2,0,L|333:319,1,25,2|0,2:2|2:0,2:0:0:0: +277,272,256337,2,0,L|261:295,1,25,0|0,2:0|2:0,2:0:0:0: +242,224,256449,2,0,L|216:233,1,25,10|0,2:2|2:0,2:0:0:0: +224,168,256560,2,0,L|251:159,1,25,0|0,2:0|2:0,2:0:0:0: +190,120,256671,2,0,L|207:97,1,25,2|0,2:2|2:0,2:0:0:0: +133,101,256782,2,0,L|133:73,1,25,0|0,2:0|2:0,2:0:0:0: +77,120,256893,2,8,L|61:97,1,25,8|0,2:2|2:0,2:0:0:0: +42,168,257004,2,8,L|16:159,1,25,8|0,2:2|2:0,2:0:0:0: +42,227,257115,2,0,L|15:236,1,25,4|0,2:2|2:0,2:0:0:0: +76,275,257226,2,0,L|59:298,1,25,0|0,2:0|2:0,2:0:0:0: +133,294,257337,2,0,L|133:322,1,25,8|0,2:2|2:0,2:0:0:0: +189,275,257449,2,8,L|205:298,1,25,0|0,2:0|2:0,2:0:0:0: +224,227,257560,2,8,L|250:236,1,25,12|0,2:2|2:0,2:0:0:0: +224,168,257671,2,8,L|250:159,1,25,0|0,2:0|2:0,2:0:0:0: +190,120,257782,5,8,0:0:0:0: +133,101,257893,1,8,0:0:0:0: +77,120,258004,1,8,0:0:0:0: +224,227,258226,1,8,0:0:0:0: +258,275,258337,1,8,0:0:0:0: +315,294,258449,1,8,0:0:0:0: +371,275,258560,1,8,0:0:0:0: +406,227,258671,1,8,0:0:0:0: +412,167,258782,1,8,0:0:0:0: +391,110,258893,6,0,L|326:101,2,50,0|12|8,0:0|0:3|0:0,0:0:0:0: +315,194,259226,1,0,1:0:0:0: +315,194,259337,2,0,L|250:203,1,50,8|8,0:0|0:0,0:0:0:0: +88,95,259560,6,0,P|158:52|237:76,1,170.000003242493,12|0,0:2|0:0,0:0:0:0: +362,269,259893,1,0,0:0:0:0: +272,288,260004,1,8,0:2:0:0: +194,241,260115,1,0,0:0:0:0: +171,153,260226,2,0,L|178:58,2,85.0000016212464,0|0|8,0:0|0:0|0:2,0:0:0:0: +106,216,260560,1,0,0:0:0:0: +24,177,260671,2,0,L|29:261,1,85.0000016212464 +165,349,260893,2,0,L|170:264,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +394,84,261115,6,0,P|343:147|251:128,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +394,84,261449,1,0,0:0:0:0: +425,169,261560,1,0,0:0:0:0: +388,252,261671,1,0,0:0:0:0: +303,285,261782,2,0,P|268:267|206:267,2,85.0000016212464,10|2|0,0:2|0:2|0:0,0:0:0:0: +454,347,262115,2,0,L|463:165,1,170.000003242493,2|0,0:2|0:0,0:0:0:0: +297,56,262449,6,0,P|294:98|309:137,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +491,104,262671,2,0,P|443:101|409:118,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +223,263,262893,2,0,L|215:157,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +68,53,263115,1,8,0:2:0:0: +89,55,263226,1,0,0:0:0:0: +110,57,263337,1,0,0:0:0:0: +131,60,263449,1,0,0:0:0:0: +152,62,263560,6,0,P|237:73|284:142,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +87,182,263893,1,0,0:0:0:0: +87,182,264004,2,0,P|39:237|58:348,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +269,306,264337,1,0,0:0:0:0: +269,306,264449,2,0,L|186:290,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +283,137,264671,6,0,B|406:160|406:160|360:178,1,170.000003242493,2|8,0:2|0:2,0:0:0:0: +165,193,265004,1,0,0:0:0:0: +165,193,265115,2,0,B|150:152|150:152|155:110,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +299,286,265337,2,0,P|339:271|391:274,2,85.0000016212464,10|2|0,0:2|0:2|0:0,0:0:0:0: +428,374,265671,1,2,0:2:0:0: +487,305,265782,1,8,0:2:0:0: +476,215,265893,1,0,0:0:0:0: +396,164,266004,6,0,L|274:178,1,85.0000016212464 +193,245,266226,2,0,L|108:235,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +210,73,266449,2,0,P|253:58|306:64,1,85.0000016212464 +438,263,266671,6,0,B|387:245|392:181|392:181|370:212|323:212,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +153,138,267004,1,0,0:0:0:0: +66,161,267115,1,8,0:2:0:0: +34,245,267226,1,0,0:0:0:0: +84,319,267337,2,0,P|168:311|263:307,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +334,211,267671,1,0,0:0:0:0: +334,211,267782,2,0,P|325:170|336:122,1,85.0000016212464 +458,77,268004,2,0,P|467:118|456:166,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +321,354,268226,6,0,P|253:306|164:315,1,170.000003242493,2|8,0:2|0:2,0:0:0:0: +34,201,268560,1,0,0:0:0:0: +34,202,268671,2,0,L|129:194,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +263,87,268893,2,0,P|177:90|129:157,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +326,305,269226,1,2,0:2:0:0: +334,211,269337,1,8,0:2:0:0: +343,118,269449,1,0,0:0:0:0: +352,25,269560,6,0,L|362:109,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +223,176,269782,2,0,L|212:260,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +446,266,270004,2,0,P|408:240|357:234,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +113,288,270226,6,0,P|65:224|76:127,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +165,55,270560,1,0,0:0:0:0: +232,116,270671,1,8,0:2:0:0: +323,130,270782,1,0,0:0:0:0: +407,90,270893,2,0,P|426:124|432:182,1,85.0000016212464 +317,319,271115,5,8,0:2:0:0: +317,225,271226,1,0,0:0:0:0: +406,196,271337,1,0,0:0:0:0: +460,272,271449,1,0,0:0:0:0: +406,348,271560,1,8,0:2:0:0: +317,319,271671,1,0,0:0:0:0: +224,303,271782,2,0,P|178:303|138:324,1,85.0000016212464 +43,164,272004,5,10,0:2:0:0: +127,124,272115,1,0,0:0:0:0: +117,217,272226,1,2,0:2:0:0: +43,164,272337,5,0,0:0:0:0: +77,48,272449,1,10,0:2:0:0: +157,138,272560,1,0,0:0:0:0: +43,164,272671,2,2,P|39:120|56:80,1,85.0000016212464,2|0,0:2|0:0,0:1:0:0: +279,26,272893,6,0,L|416:16,1,85.0000016212464,10|8,0:2|0:2,0:0:0:0: +504,120,273115,2,0,L|367:130,1,85.0000016212464,0|8,0:0|0:2,0:0:0:0: +176,120,273337,5,10,0:2:0:0: +216,208,273449,1,8,0:2:0:0: +184,296,273560,1,8,0:2:0:0: +112,352,273671,1,8,0:2:0:0: +16,344,273782,6,0,L|224:328,1,170.000003242493,12|0,0:2|0:0,0:0:0:0: +464,288,274115,2,0,P|392:312|288:248,1,170.000003242493 +105,186,274449,5,0,0:0:0:0: +143,95,274560,1,0,0:0:0:0: +233,58,274671,1,8,0:2:0:0: +324,95,274782,1,0,3:0:0:0: +361,186,274893,1,0,0:0:0:0: +324,276,275004,1,0,0:0:0:0: +233,314,275115,6,0,L|128:320,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +0,216,275337,2,0,P|96:168|192:232,1,170.000003242493,0|8,0:0|0:2,0:0:0:0: +324,276,275671,1,0,0:0:0:0: +392,336,275782,2,0,P|413:299|414:257,1,85.0000016212464 +320,155,276004,6,0,P|272:217|324:276,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +424,368,276337,2,0,L|456:200,1,170.000003242493,2|0,0:2|0:0,0:0:0:0: +360,24,276671,6,0,P|352:64|408:112,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +232,112,276893,2,0,P|192:104|144:160,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +200,296,277115,2,0,P|192:256|248:208,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +360,144,277337,5,8,0:2:0:0: +360,144,277449,1,0,0:0:0:0: +360,144,277560,1,0,0:0:0:0: +360,144,277671,1,0,0:0:0:0: +360,144,277782,2,0,L|336:328,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +256,352,278115,1,0,0:0:0:0: +168,328,278226,2,0,P|241:264|351:328,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +184,352,278560,5,0,0:0:0:0: +184,352,278671,2,0,L|80:360,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +307,274,278893,2,0,P|400:304|480:264,1,170.000003242493,2|8,0:2|0:2,0:0:0:0: +504,136,279226,5,0,0:0:0:0: +504,137,279337,2,0,L|415:124,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +232,64,279560,2,0,P|200:136|256:200,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +488,264,279893,5,2,0:2:0:0: +400,232,280004,1,8,0:2:0:0: +312,256,280115,1,0,0:0:0:0: +248,320,280226,6,0,P|280:352|336:344,1,85.0000016212464 +120,280,280449,2,0,L|24:272,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +216,192,280671,2,0,P|264:184|312:200,1,85.0000016212464 +160,104,280893,6,0,P|64:136|8:80,1,170.000003242493,8|0,0:2|0:0,0:0:0:0: +201,22,281226,2,0,L|217:214,1,170.000003242493 +376,336,281560,5,0,0:0:0:0: +416,248,281671,1,0,0:0:0:0: +320,264,281782,1,8,0:2:0:0: +376,336,281893,1,0,0:0:0:0: +416,248,282004,6,0,P|421:207|416:128,1,85.0000016212464 +312,56,282226,2,0,P|306:96|312:176,1,85.0000016212464,8|0,0:2|0:0,0:0:0:0: +200,256,282449,5,0,0:0:0:0: +185,163,282560,1,0,0:0:0:0: +92,148,282671,1,8,0:2:0:0: +51,231,282782,1,0,0:0:0:0: +116,298,282893,6,0,L|232:304,1,85.0000016212464 +448,264,283115,2,0,P|368:232|248:304,1,170.000003242493,10|0,0:2|0:0,0:0:0:0: +368,328,283449,2,0,L|392:96,1,170.000003242493,2|0,0:2|0:0,0:0:0:0: +288,32,283782,6,0,L|304:160,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +185,163,284004,2,0,L|128:80,1,85.0000016212464,10|0,0:2|0:0,0:0:0:0: +51,231,284226,2,0,L|160:264,1,85.0000016212464,2|0,0:2|0:0,0:0:0:0: +360,336,284449,6,0,L|424:352,1,42.5000008106232,10|0,3:2|3:0,0:0:0:0: +384,304,284560,2,0,L|440:320,1,42.5000008106232,0|4,3:0|3:2,0:0:0:0: +408,272,284671,2,0,L|472:288,1,42.5000008106232,2|0,3:2|3:0,0:0:0:0: +432,240,284782,2,0,L|496:256,1,42.5000008106232,0|0,3:0|3:0,0:0:0:0: +376,160,284893,6,0,L|312:176,1,42.5000008106232,10|0,3:2|3:0,0:0:0:0: +352,128,285004,2,0,L|296:144,1,42.5000008106232,0|0,3:0|3:0,0:0:0:0: +328,96,285115,2,0,L|264:112,1,42.5000008106232,2|0,3:2|3:0,0:0:0:0: +304,64,285226,2,0,L|240:80,1,42.5000008106232,0|0,3:0|3:0,0:0:0:0: +160,64,285337,6,0,L|160:8,1,42.5000008106232,8|0,3:2|3:0,0:0:0:0: +112,88,285449,2,0,L|72:48,1,42.5000008106232,0|0,3:0|3:0,0:0:0:0: +84,136,285560,2,2,L|28:136,1,42.5000008106232,4|0,3:2|2:0,0:1:0:0: +84,192,285671,2,0,L|44:231,1,42.5000008106232,0|0,2:0|2:0,2:0:0:0: +176,292,285782,6,0,L|176:348,1,42.5000008106232,8|0,2:2|2:0,2:0:0:0: +216,264,285893,2,0,L|255:303,1,42.5000008106232,0|0,2:0|2:0,2:0:0:0: +240,224,286004,2,2,L|296:224,1,42.5000008106232,2|0,2:2|2:0,2:1:0:0: +252,176,286115,2,0,L|291:136,1,42.5000008106232,0|0,2:0|2:0,2:0:0:0: +164,104,286226,5,8,0:2:0:0: +84,136,286337,1,8,2:0:0:0: +52,216,286449,1,8,2:0:0:0: +216,264,286671,1,8,0:0:0:0: +248,184,286782,1,8,0:0:0:0: +328,152,286893,2,0,L|328:104,2,42.5000008106232,8|0|0,3:2|3:0|3:0,0:0:0:0: +400,184,287115,1,8,0:0:0:0: +440,264,287226,5,0,0:0:0:0: +440,280,287449,1,4,0:3:0:0: +440,296,287671,1,0,0:0:0:0: +160,176,288004,6,0,L|337:155,1,150.000005722046,4|2,0:0|0:0,0:0:0:0: +335,290,288337,1,0,0:0:0:0: +335,290,288449,2,0,L|260:281,1,75.0000028610231,8|0,3:2|3:0,0:0:0:0: +136,148,288671,2,0,L|115:231,1,75.0000028610231,2|0,3:0|0:0,0:0:0:0: +254,138,288893,5,10,0:0:0:0: +223,70,289004,1,0,0:0:0:0: +160,33,289115,1,0,3:0:0:0: +86,39,289226,2,0,P|53:60|30:98,1,75.0000028610231,0|8,3:0|0:0,0:0:0:0: +117,220,289449,1,0,0:0:0:0: +117,220,289560,2,0,L|128:309,2,75.0000028610231,2|0|8,3:0|3:0|3:2,0:0:0:0: +228,182,289893,5,0,3:0:0:0: +268,192,290004,1,2,3:0:0:0: +300,161,290115,1,0,0:0:0:0: +341,171,290226,2,0,L|419:152,1,75.0000028610231,10|0,3:2|3:0,0:0:0:0: +243,96,290449,2,0,L|321:77,1,75.0000028610231,2|0,3:2|0:0,0:0:0:0: +140,30,290671,6,0,L|56:51,1,75.0000028610231,10|0,3:2|3:0,0:0:0:0: +223,123,290893,1,0,3:0:0:0: +223,123,291004,2,0,L|139:144,1,75.0000028610231,2|8,0:2|3:2,0:0:0:0: +306,216,291226,1,0,3:0:0:0: +233,234,291337,2,0,L|149:255,1,75.0000028610231,2|2,3:2|3:2,0:0:0:0: +60,82,291560,5,8,0:2:0:0: +114,95,291671,1,0,3:0:0:0: +169,109,291782,1,2,3:0:0:0: +223,123,291893,1,0,0:0:0:0: +277,108,292004,2,0,P|311:94|348:92,1,75.0000028610231,8|0,0:2|0:0,0:0:0:0: +479,209,292226,2,0,P|495:244|499:299,1,75.0000028610231,2|0,3:0|3:0,0:0:0:0: +285,299,292449,6,0,L|279:377,1,75.0000028610231,10|0,3:2|3:0,0:0:0:0: +407,190,292671,1,0,3:0:0:0: +407,190,292782,2,0,L|413:268,1,75.0000028610231,2|8,0:2|3:2,0:0:0:0: +253,128,293004,1,0,3:0:0:0: +248,203,293115,2,0,B|246:234|246:234|273:164|352:157,1,150.000005722046,2|8,3:2|0:2,0:0:0:0: +482,90,293449,5,0,0:0:0:0: +487,164,293560,2,0,L|406:144,1,75.0000028610231,2|0,0:3|0:0,0:0:0:0: +248,203,293782,1,8,3:2:0:0: +196,180,293893,1,0,3:0:0:0: +140,180,294004,1,0,3:0:0:0: +89,202,294115,1,0,3:0:0:0: +51,243,294226,6,0,B|12:200|46:133|46:133|43:157|54:173,1,150.000005722046,10|2,0:2|1:2,0:0:0:0: +92,319,294560,2,0,P|129:312|166:310,1,75.0000028610231,4|8,0:3|0:0,0:0:0:0: +317,351,294782,1,2,0:0:0:0: +399,338,294893,2,0,P|434:311|453:285,1,75.0000028610231,2|0,1:2|0:0,0:0:0:0: +281,104,295115,5,8,0:0:0:0: +247,147,295226,1,0,3:0:0:0: +248,203,295337,2,0,P|290:210|334:204,1,75.0000028610231,2|0,3:0|0:0,0:0:0:0: +281,104,295560,6,0,P|199:131|256:202,1,200,2|2,1:0|0:0,0:0:0:0: +301,78,295893,6,0,L|199:61,1,75.0000028610231,0|8,0:0|0:0,0:0:0:0: +322,206,296115,1,0,3:0:0:0: +378,203,296226,1,2,3:0:0:0: +434,200,296337,1,0,3:0:0:0: +490,197,296449,2,0,P|492:159|482:124,1,75.0000028610231,8|0,0:0|0:0,0:0:0:0: +384,359,296671,2,0,P|418:345|446:320,1,75.0000028610231,2|0,3:0|3:0,0:0:0:0: +295,164,296893,5,8,3:2:0:0: +242,144,297004,1,0,3:2:0:0: +187,149,297115,1,0,3:2:0:0: +140,179,297226,1,0,0:0:0:0: +112,227,297337,2,0,L|104:142,1,75.0000028610231,8|0,0:0|0:0,0:0:0:0: +0,63,297560,2,0,P|34:76|71:80,1,75.0000028610231,2|0,3:0|3:0,0:0:0:0: +259,18,297782,6,0,P|221:22|187:35,2,75.0000028610231,10|0|0,0:0|3:0|0:0,0:0:0:0: +383,73,298115,2,0,P|404:103|418:138,2,75.0000028610231,2|8|0,0:0|3:2|0:0,0:0:0:0: +242,144,298449,2,0,L|248:238,1,75.0000028610231,4|2,0:1|0:0,0:0:0:0: +409,298,298671,6,0,P|336:294|258:343,1,150.000005722046,12|0,0:0|3:0,0:0:0:0: +47,287,299115,1,10,0:0:0:0: +11,243,299226,1,0,3:0:0:0: +0,189,299337,1,2,3:0:0:0: +13,135,299449,1,0,0:0:0:0: +49,92,299560,6,0,B|80:80|80:80|136:91,1,75.0000028610231,10|0,0:0|3:0,0:0:0:0: +244,186,299782,1,0,3:0:0:0: +244,186,299893,2,0,B|213:198|213:198|172:189,1,75.0000028610231,2|8,0:0|0:0,0:0:0:0: +363,223,300115,1,0,0:0:0:0: +363,223,300226,2,0,L|357:126,1,75.0000028610231,10|0,1:2|0:0,0:0:0:0: +449,56,300449,6,0,L|455:13,3,37.5000014305115,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +403,162,300671,2,0,L|406:208,3,37.5000014305115,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +340,71,300893,2,0,L|296:67,3,37.5000014305115,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +254,153,301115,2,0,L|210:157,3,37.5000014305115,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +120,88,301337,6,0,L|88:62,1,37.5000014305115,10|0,0:0|0:0,0:0:0:0: +66,100,301449,2,0,L|21:88,1,37.5000014305115,8|0,0:0|0:0,0:0:0:0: +30,141,301560,2,0,L|-10:146,1,37.5000014305115,10|0,0:0|0:0,0:0:0:0: +23,196,301671,2,0,L|-21:218,1,37.5000014305115,10|0,0:0|0:0,0:0:0:0: +50,245,301782,1,8,0:0:0:0: +100,226,301893,1,10,0:0:0:0: +146,250,302004,1,10,0:0:0:0: +159,300,302115,1,8,0:0:0:0: +136,344,302226,6,0,L|112:152,1,150.000005722046,12|0,0:0|0:0,0:0:0:0: +192,80,302560,1,0,0:0:0:0: +192,80,302671,2,0,P|248:136|208:232,1,150.000005722046,8|2,3:2|3:0,0:0:0:0: +146,250,303004,5,0,0:0:0:0: +72,304,303115,2,0,P|56:264|56:224,1,75.0000028610231,10|0,0:0|0:0,0:0:0:0: +106,129,303337,6,0,P|158:179|146:250,1,150.000005722046,0|8,3:0|0:0,0:0:0:0: +72,152,303671,1,0,0:0:0:0: +72,152,303782,2,0,L|64:48,1,75.0000028610231,2|0,3:0|3:0,0:0:0:0: +168,0,304004,1,8,3:2:0:0: +168,0,304115,2,0,L|176:104,1,75.0000028610231,0|2,3:0|3:0,0:0:0:0: +224,184,304337,5,0,0:0:0:0: +159,247,304449,2,0,P|228:273|310:179,1,150.000005722046,10|2,3:2|3:2,0:0:0:0: +428,142,304782,1,0,0:0:0:0: +428,142,304893,2,0,L|336:147,1,75.0000028610231,10|0,3:2|3:0,0:0:0:0: +263,73,305115,5,0,3:0:0:0: +213,56,305226,1,4,0:3:0:0: +161,64,305337,1,8,3:2:0:0: +122,99,305449,1,0,3:0:0:0: +104,148,305560,2,0,L|122:225,1,75.0000028610231,2|2,3:2|3:2,0:0:0:0: +166,316,305782,6,0,B|96:318|90:334|90:334|65:342|12:324,1,150.000005722046,8|2,0:0|3:0,0:0:0:0: +309,200,306115,1,0,0:0:0:0: +309,200,306226,2,0,P|336:253|307:351,1,150.000005722046,8|2,0:0|3:0,0:0:0:0: +243,278,306560,5,0,3:0:0:0: +309,200,306671,2,0,L|391:196,1,75.0000028610231,10|0,3:2|3:0,0:0:0:0: +500,99,306893,5,0,3:0:0:0: +447,92,307004,1,2,0:2:0:0: +397,75,307115,1,8,3:2:0:0: +344,69,307226,1,0,3:0:0:0: +296,47,307337,2,0,L|207:35,1,75.0000028610231,2|0,3:2|0:0,0:0:0:0: +108,8,307560,6,0,B|110:50|110:50|124:100|124:100|105:170,1,150.000005722046,8|2,0:0|3:0,0:0:0:0: +78,336,307893,1,0,0:0:0:0: +78,336,308004,2,0,P|31:298|55:174,1,150.000005722046,8|0,3:2|3:0,0:0:0:0: +194,174,308337,1,0,3:0:0:0: +194,174,308449,2,0,L|186:258,1,75.0000028610231,10|0,0:2|0:0,0:0:0:0: +288,113,308671,5,2,1:2:0:0: +323,74,308782,1,2,0:0:0:0: +373,59,308893,1,8,0:0:0:0: +424,70,309004,1,2,0:0:0:0: +476,67,309115,5,2,1:2:0:0: +462,117,309226,1,0,0:0:0:0: +448,167,309337,1,8,0:0:0:0: +434,217,309449,1,0,3:0:0:0: +460,262,309560,5,2,3:0:0:0: +408,253,309671,1,0,0:0:0:0: +359,274,309782,2,0,P|343:319|405:383,1,75.0000028610231,2|0,1:0|0:0,0:0:0:0: +305,191,310004,1,2,0:0:0:0: +305,191,310115,2,0,L|329:18,1,150.000005722046,0|0,0:0|3:0,0:0:0:0: +216,296,310449,5,2,3:0:0:0: +165,310,310560,1,0,3:0:0:0: +118,290,310671,2,0,P|80:264|128:141,1,150.000005722046,8|2,0:0|3:0,0:0:0:0: +230,113,311004,5,0,0:0:0:0: +230,113,311115,2,0,L|-10:130,1,225.000008583069,8|0,3:2|0:0,0:0:0:0: +95,62,311560,6,0,P|132:80|151:116,1,75.0000028610231,8|0,0:0|0:0,0:0:0:0: +169,270,311782,1,2,3:0:0:0: +169,270,311893,2,0,P|248:233|338:273,1,150.000005722046,0|0,3:0|3:0,0:0:0:0: +405,327,312226,6,0,L|489:330,1,75.0000028610231,0|4,3:0|0:3,0:0:0:0: +200,285,312449,2,0,P|129:302|52:269,1,150.000005722046,8|4,0:0|0:1,0:0:0:0: +13,105,312782,5,0,0:0:0:0: +95,62,312893,2,0,L|328:80,1,225.000008583069,12|2,0:0|0:0,0:0:0:0: +488,272,313337,6,0,P|480:208|432:168,1,75.0000028610231,8|0,0:0|1:0,0:0:0:0: +360,168,313560,2,0,P|368:232|416:272,1,75.0000028610231,0|0,3:2|1:0,0:0:0:0: +464,312,313782,6,0,L|440:176,1,75.0000028610231,8|0,3:2|3:0,0:0:0:0: +320,144,314004,2,0,L|344:280,1,75.0000028610231,2|0,3:0|3:0,0:0:0:0: +288,352,314226,5,8,0:0:0:0: +248,320,314337,1,0,0:0:0:0: +208,304,314449,2,0,L|144:304,1,37.5000014305115,0|0,3:0|3:0,0:0:0:0: +168,304,314560,2,0,L|112:312,1,37.5000014305115,0|0,3:0|3:0,0:0:0:0: +112,320,314671,5,8,3:2:0:0: +64,312,314782,1,0,3:0:0:0: +32,272,314893,1,8,3:2:0:0: +40,216,315004,1,0,0:0:0:0: +72,176,315115,5,8,0:0:0:0: +120,160,315226,1,0,0:0:0:0: +168,144,315337,2,0,L|216:128,1,37.5000014305115,8|8,0:0|0:0,0:0:0:0: +203,132,315449,2,0,L|264:112,1,37.5000014305115,8|8,0:0|0:0,0:0:0:0: +264,136,315560,5,8,0:0:0:0: +296,96,315671,1,8,0:0:0:0: +288,48,315782,1,8,0:0:0:0: +256,8,315893,5,8,0:0:0:0: +256,352,316115,1,4,0:1:0:0: +256,8,316337,1,8,0:0:0:0: +219,91,316449,6,0,P|270:95|322:71,1,100,12|0,0:0|0:0,0:0:0:0: +434,29,316893,1,8,0:0:0:0: +437,66,317004,1,0,0:0:0:0: +440,103,317115,1,0,0:0:0:0: +371,215,317337,2,0,L|321:205,3,50,10|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +298,247,317782,2,0,L|248:256,3,50,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +216,221,318226,5,8,0:0:0:0: +178,217,318337,1,0,0:0:0:0: +143,229,318449,1,2,0:0:0:0: +23,142,318671,1,8,0:0:0:0: +161,78,318893,1,2,0:0:0:0: +56,224,319115,1,10,0:0:0:0: +36,255,319226,1,0,0:0:0:0: +31,292,319337,1,0,0:0:0:0: +40,328,319449,1,4,0:3:0:0: +62,358,319560,2,0,L|124:345,1,50,8|0,0:0|0:0,0:0:0:0: +249,272,319782,6,0,L|373:292,1,100,0|8,0:0|0:0,0:0:0:0: +219,363,320226,1,2,0:0:0:0: +260,226,320449,1,8,0:0:0:0: +380,331,320671,1,2,0:0:0:0: +462,168,320893,1,10,0:0:0:0: +420,173,321004,1,0,0:0:0:0: +379,178,321115,2,0,L|320:193,1,50,2|0,0:0|0:0,0:0:0:0: +200,259,321337,2,0,L|259:274,1,50,10|0,0:0|0:0,0:0:0:0: +380,331,321560,6,0,P|395:283|381:223,1,100,2|8,0:0|0:0,0:0:0:0: +338,49,322004,1,0,0:0:0:0: +330,190,322226,1,8,0:0:0:0: +443,275,322449,1,0,0:0:0:0: +426,118,322671,1,8,0:0:0:0: +394,97,322782,1,0,0:0:0:0: +360,83,322893,1,0,0:0:0:0: +323,76,323004,1,4,0:3:0:0: +285,78,323115,2,0,P|261:78|223:92,1,50,8|0,0:0|0:0,0:0:0:0: +165,223,323337,2,0,P|181:204|196:166,1,50 +65,126,323560,5,8,3:2:0:0: +43,156,323671,1,0,3:0:0:0: +30,191,323782,1,2,3:0:0:0: +27,229,323893,1,0,0:0:0:0: +34,265,324004,1,8,3:2:0:0: +69,278,324115,1,0,3:0:0:0: +106,283,324226,1,2,3:0:0:0: +144,280,324337,1,0,0:0:0:0: +179,269,324449,5,8,0:0:0:0: +205,294,324560,1,0,0:0:0:0: +205,331,324671,1,0,3:0:0:0: +179,356,324782,1,2,3:0:0:0: +143,356,324893,1,10,0:0:0:0: +120,319,325004,1,0,3:0:0:0: +111,276,325115,1,2,3:0:0:0: +118,233,325226,1,0,0:0:0:0: +139,195,325337,5,8,3:2:0:0: +168,203,325449,1,0,3:0:0:0: +199,204,325560,1,2,3:0:0:0: +230,196,325671,1,0,0:0:0:0: +256,180,325782,1,8,3:2:0:0: +282,164,325893,1,0,3:0:0:0: +313,156,326004,1,2,3:0:0:0: +344,157,326115,1,0,0:0:0:0: +374,166,326226,5,10,3:2:0:0: +334,137,326337,1,0,3:2:0:0: +318,90,326449,1,0,3:2:0:0: +331,43,326560,1,6,3:3:0:0: +370,12,326671,1,8,0:0:0:0: +419,9,326782,1,2,3:0:0:0: +461,35,326893,1,0,3:0:0:0: +480,80,327004,1,0,3:0:0:0: +470,128,327115,5,12,0:0:0:0: +448,166,327226,1,0,3:0:0:0: +444,209,327337,1,0,3:0:0:0: +458,250,327449,1,0,0:0:0:0: +487,282,327560,1,10,3:0:0:0: +475,241,327671,1,0,3:0:0:0: +442,213,327782,1,2,3:0:0:0: +400,208,327893,1,0,0:0:0:0: +361,227,328004,5,8,3:2:0:0: +336,258,328115,1,0,3:0:0:0: +275,214,328226,1,0,3:0:0:0: +261,246,328337,1,2,3:0:0:0: +192,227,328449,1,8,0:0:0:0: +186,256,328560,1,0,3:0:0:0: +114,258,328671,1,2,3:0:0:0: +113,282,328782,1,0,0:0:0:0: +45,304,328893,6,0,L|17:322,3,25,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +25,203,329115,2,0,L|-7:207,3,25,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +69,115,329337,2,0,L|37:105,3,25,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +154,70,329560,2,0,L|129:47,3,25,8|0|0|0,0:0|0:0|0:0|0:0,0:0:0:0: +300,116,329782,6,0,L|336:103,1,25,8|0,3:2|3:0,0:0:0:0: +282,168,329893,2,0,L|305:198,1,25,8|0,3:2|0:0,0:0:0:0: +228,168,330004,2,0,L|206:199,1,25,8|0,3:2|3:2,0:0:0:0: +211,115,330115,2,0,L|174:104,1,25,12|0,3:0|3:0,0:0:0:0: +256,83,330226,5,8,0:0:0:0: +256,133,330337,1,8,0:0:0:0: +256,183,330449,1,8,0:0:0:0: +256,233,330560,1,4,0:0:0:0: diff --git a/osu.Game.Rulesets.Osu.Tests/StackingTest.cs b/osu.Game.Rulesets.Osu.Tests/StackingTest.cs index e370807bca33..6daf5c59c6aa 100644 --- a/osu.Game.Rulesets.Osu.Tests/StackingTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/StackingTest.cs @@ -4,8 +4,8 @@ using System; using System.IO; using System.Linq; -using System.Text; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.IO; using osu.Game.Rulesets.Mods; @@ -19,23 +19,9 @@ namespace osu.Game.Rulesets.Osu.Tests public class StackingTest { [Test] - public void TestStacking() + public void TestStackingEdgeCaseOne() { - using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(beatmap_data))) - using (var reader = new LineBufferedReader(stream)) - { - var beatmap = Decoder.GetDecoder(reader).Decode(reader); - var converted = new TestWorkingBeatmap(beatmap).GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()); - - var objects = converted.HitObjects.ToList(); - - // The last hitobject triggers the stacking - for (int i = 0; i < objects.Count - 1; i++) - Assert.AreEqual(0, ((OsuHitObject)objects[i]).StackHeight); - } - } - - private const string beatmap_data = @" + using (var stream = new MemoryStream(@" osu file format v14 [General] @@ -62,6 +48,65 @@ osu file format v14 311,185,218471,2,0,L|325:209,1,25 311,185,218671,2,0,L|304:212,1,25 311,185,240271,5,0,0:0:0:0: -"; +"u8.ToArray())) + using (var reader = new LineBufferedReader(stream)) + { + var beatmap = Decoder.GetDecoder(reader).Decode(reader); + var converted = new TestWorkingBeatmap(beatmap).GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()); + + var objects = converted.HitObjects.ToList(); + + // The last hitobject triggers the stacking + for (int i = 0; i < objects.Count - 1; i++) + ClassicAssert.AreEqual(0, ((OsuHitObject)objects[i]).StackHeight); + } + } + + [Test] + public void TestStackingEdgeCaseTwo() + { + using (var stream = new MemoryStream(@" +osu file format v14 +// extracted from https://osu.ppy.sh/beatmapsets/365006#osu/801165 + +[General] +StackLeniency: 0.2 + +[Difficulty] +HPDrainRate:6 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9.3 +SliderMultiplier:2 +SliderTickRate:1 + +[TimingPoints] +5338,444.444444444444,4,2,0,50,1,0 +82893,-76.9230769230769,4,2,8,50,0,0 +85115,-76.9230769230769,4,2,0,50,0,0 +85337,-100,4,2,8,60,0,0 +85893,-100,4,2,7,60,0,0 +86226,-100,4,2,8,60,0,0 +88893,-58.8235294117647,4,1,8,70,0,1 + +[HitObjects] +427,124,84226,1,0,3:0:0:0: +427,124,84337,1,0,3:0:0:0: +427,124,84449,1,8,0:0:0:0: +"u8.ToArray())) + using (var reader = new LineBufferedReader(stream)) + { + var beatmap = Decoder.GetDecoder(reader).Decode(reader); + var converted = new TestWorkingBeatmap(beatmap).GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()); + + var objects = converted.HitObjects.ToList(); + + Assert.That(objects, Has.Count.EqualTo(3)); + + // The last hitobject triggers the stacking + for (int i = 0; i < objects.Count - 1; i++) + ClassicAssert.AreEqual(0, ((OsuHitObject)objects[i]).StackHeight); + } + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneAutoGeneration.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneAutoGeneration.cs new file mode 100644 index 000000000000..e2c66af19f85 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneAutoGeneration.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.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + [HeadlessTest] + public partial class TestSceneAutoGeneration : OsuTestScene + { + [TestCase(-1, true)] + [TestCase(0, false)] + [TestCase(1, false)] + public void TestAlternating(double offset, bool shouldAlternate) + { + const double first_object_time = 1000; + double secondObjectTime = first_object_time + AutoGenerator.KEY_UP_DELAY + OsuAutoGenerator.MIN_FRAME_SEPARATION_FOR_ALTERNATING + offset; + + var beatmap = new OsuBeatmap(); + beatmap.HitObjects.Add(new HitCircle { StartTime = first_object_time }); + beatmap.HitObjects.Add(new HitCircle { StartTime = secondObjectTime }); + + var generated = new OsuAutoGenerator(beatmap, []).Generate(); + var frames = generated.Frames.OfType().ToList(); + + Assert.That(frames.Exists(f => f.Time == first_object_time && f.Actions.SingleOrDefault() == OsuAction.LeftButton)); + Assert.That(frames.Exists(f => f.Time == first_object_time + AutoGenerator.KEY_UP_DELAY && !f.Actions.Any())); + + Assert.That(frames.Exists(f => f.Time == secondObjectTime && f.Actions.SingleOrDefault() == (shouldAlternate ? OsuAction.RightButton : OsuAction.LeftButton))); + Assert.That(frames.Exists(f => f.Time == secondObjectTime + AutoGenerator.KEY_UP_DELAY && !f.Actions.Any())); + } + + [TestCase(300)] + [TestCase(600)] + [TestCase(1200)] + public void TestAlternatingSpecificBPM(double bpm) + { + const double first_object_time = 1000; + double secondObjectTime = first_object_time + 60000 / bpm; + + var beatmap = new OsuBeatmap(); + beatmap.HitObjects.Add(new HitCircle { StartTime = first_object_time }); + beatmap.HitObjects.Add(new HitCircle { StartTime = secondObjectTime }); + + var generated = new OsuAutoGenerator(beatmap, []).Generate(); + var frames = generated.Frames.OfType().ToList(); + + Assert.That(frames.Exists(f => f.Time == first_object_time && f.Actions.SingleOrDefault() == OsuAction.LeftButton)); + Assert.That(frames.Exists(f => f.Time == first_object_time + AutoGenerator.KEY_UP_DELAY && !f.Actions.Any())); + + Assert.That(frames.Exists(f => f.Time == secondObjectTime && f.Actions.SingleOrDefault() == OsuAction.RightButton)); + Assert.That(frames.Exists(f => f.Time == secondObjectTime + AutoGenerator.KEY_UP_DELAY && !f.Actions.Any())); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursorSizeChange.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursorSizeChange.cs new file mode 100644 index 000000000000..c94e575032bb --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursorSizeChange.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneGameplayCursorSizeChange : PlayerTestScene + { + private const float initial_cursor_size = 1f; + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + [Resolved] + private SkinManager? skins { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + if (skins != null) skins.CurrentSkinInfo.Value = skins.DefaultClassicSkin.SkinInfo; + } + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("Set gameplay cursor size: 1", () => LocalConfig.SetValue(OsuSetting.GameplayCursorSize, initial_cursor_size)); + AddStep("resume player", () => Player.GameplayClockContainer.Start()); + AddUntilStep("clock running", () => Player.GameplayClockContainer.IsRunning); + } + + [Test] + public void TestPausedChangeCursorSize() + { + AddStep("move cursor to center", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.Centre)); + AddStep("move cursor to top left", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft)); + AddStep("move cursor to center", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.Centre)); + AddStep("move cursor to top right", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopRight)); + AddStep("press escape", () => InputManager.Key(Key.Escape)); + + AddSliderStep("cursor size", 0.1f, 2f, 1f, v => LocalConfig.SetValue(OsuSetting.GameplayCursorSize, v)); + } + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(true, false); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs index 059951789987..51544b0c3822 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs @@ -33,7 +33,7 @@ protected override TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circl Debug.Assert(drawableHitObject.HitObject.HitWindows != null); double delay = drawableHitObject.HitObject.StartTime - (drawableHitObject.HitObject.HitWindows.WindowFor(HitResult.Miss) + RNG.Next(0, 300)) - Time.Current; - scheduledTasks.Add(Scheduler.AddDelayed(() => drawableHitObject.TriggerJudgement(), delay)); + scheduledTasks.Add(Scheduler.AddDelayed(drawableHitObject.TriggerJudgement, delay)); return drawableHitObject; } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs index d5d3cbb146be..0e7d94cb9f3d 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Input.Events; @@ -10,6 +11,7 @@ using osu.Framework.Logging; using osu.Framework.Testing.Input; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Tests @@ -58,7 +60,7 @@ private void addStep(string stepName, double duration) foreach (var smokeContainer in smokeContainers) { - if (smokeContainer.Children.Count != 0) + if (smokeContainer.Children.OfType().Any()) return false; } diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index 6510568555bd..15dca424f32d 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 @@ -1,10 +1,10 @@  - - - - + + + + WinExe diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs index e49d72ef33c8..0566b7f3341d 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs @@ -91,7 +91,7 @@ private static void applyStacking(IBeatmap beatmap, List hitObject continue; double endTime = stackBaseObject.GetEndTime(); - double stackThreshold = objectN.TimePreempt * beatmap.StackLeniency; + float stackThreshold = calculateStackThreshold(beatmap, objectN); if (objectN.StartTime - endTime > stackThreshold) // We are no longer within stacking range of the next object. @@ -136,7 +136,7 @@ private static void applyStacking(IBeatmap beatmap, List hitObject OsuHitObject objectI = hitObjects[i]; if (objectI.StackHeight != 0 || objectI is Spinner) continue; - double stackThreshold = objectI.TimePreempt * beatmap.StackLeniency; + float stackThreshold = calculateStackThreshold(beatmap, objectI); /* If this object is a hitcircle, then we enter this "special" case. * It either ends with a stack of hitcircles only, or a stack of hitcircles that are underneath a slider. @@ -151,7 +151,10 @@ private static void applyStacking(IBeatmap beatmap, List hitObject double endTime = objectN.GetEndTime(); - if (objectI.StartTime - endTime > stackThreshold) + // truncation to integer is required to match stable + // compare https://github.com/peppy/osu-stable-reference/blob/08e3dafd525934cf48880b08e91c24ce4ad8b761/osu!/GameplayElements/HitObjectManager.cs#L1725 + // - both quantities being subtracted there are integers + if ((int)objectI.StartTime - (int)endTime > stackThreshold) // We are no longer within stacking range of the previous object. break; @@ -232,7 +235,7 @@ private static void applyStackingOld(IBeatmap beatmap, List hitObj for (int j = i + 1; j < hitObjects.Count; j++) { - double stackThreshold = hitObjects[i].TimePreempt * beatmap.StackLeniency; + float stackThreshold = calculateStackThreshold(beatmap, hitObjects[i]); if (hitObjects[j].StartTime - stackThreshold > startTime) break; @@ -264,5 +267,17 @@ private static void applyStackingOld(IBeatmap beatmap, List hitObj } } } + + /// + /// Truncation of to , as well as keeping the result as , are both done + /// + /// for the purposes of stable compatibility + /// . + /// Note that for top-level objects is supposed to be integral anyway; + /// see using when calculating it. + /// Slider ticks and end circles are the exception to that, but they do not matter for stacking. + /// + private static float calculateStackThreshold(IBeatmap beatmap, OsuHitObject hitObject) + => (int)hitObject.TimePreempt * beatmap.StackLeniency; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs new file mode 100644 index 000000000000..8e3c9d01bc98 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim +{ + public static class AgilityEvaluator + { + private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.25 circles distance between centers + + /// + /// Evaluates the difficulty of fast aiming + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; + + double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0; + double distance = travelDistance + osuCurrObj.LazyJumpDistance; + + double distanceScaled = Math.Min(distance, distance_cap) / distance_cap; + + double strain = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; + + strain *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5); + + strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + + return strain; + } + + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.2, ms / 1000)); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs new file mode 100644 index 000000000000..30f45a3226a5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -0,0 +1,127 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim +{ + public static class FlowAimEvaluator + { + private const double velocity_change_multiplier = 0.52; + + /// + /// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them. + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) + { + if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + return 0; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); + var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); + + double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance; + double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; + + double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + + if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) + { + // If the last object is a slider, then we extend the travel velocity through the slider into the current object. + double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; + currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime); + } + + double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; + + double flowDifficulty = currVelocity; + + // Apply high circle size bonus to the base velocity. + // We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling + flowDifficulty *= Math.Sqrt(osuCurrObj.SmallCircleBonus); + + // Rhythm changes are harder to flow + flowDifficulty *= 1 + Math.Min(0.25, + Math.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4)); + + if (osuCurrObj.Angle != null && osuLastObj.Angle != null) + { + double angleDifference = Math.Abs(osuCurrObj.Angle.Value - osuLastObj.Angle.Value); + double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0; + double angularVelocity = angleDifferenceAdjusted / (osuCurrObj.AdjustedDeltaTime * 0.1); + + // Low angular velocity flow (angles are consistent) is easier to follow than erratic flow + flowDifficulty *= 0.8 + Math.Sqrt(angularVelocity / 270.0); + } + + // If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement + double overlappedNotesWeight = 1; + + if (current.Index > 2) + { + double o1 = calculateOverlapFactor(osuCurrObj, osuLastObj); + double o2 = calculateOverlapFactor(osuCurrObj, osuLastLastObj); + double o3 = calculateOverlapFactor(osuLastObj, osuLastLastObj); + + overlappedNotesWeight = 1 - o1 * o2 * o3; + } + + if (osuCurrObj.Angle != null) + { + // Acute angles are also hard to flow + // We square root velocity to make acute angle switches in streams aren't having difficulty higher than snap + flowDifficulty += Math.Sqrt(currVelocity) * + SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) * + overlappedNotesWeight; + } + + if (Math.Max(prevVelocity, currVelocity) != 0) + { + if (withSliderTravelDistance) + { + currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + } + + // Scale with ratio of difference compared to 0.5 * max dist. + double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); + + // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. + double overlapVelocityBuff = Math.Min(OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), + Math.Abs(prevVelocity - currVelocity)); + + flowDifficulty += overlapVelocityBuff * + distRatio * + overlappedNotesWeight * + velocity_change_multiplier; + } + + if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) + { + // Include slider velocity to make velocity more consistent with snap + flowDifficulty += osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + } + + // Final velocity is being raised to a power because flow difficulty scales harder with both high distance and time, and we want to account for that + flowDifficulty = Math.Pow(flowDifficulty, 1.45); + + // Reduce difficulty for low spacing since spacing below radius is always to be flowed + return flowDifficulty * DifficultyCalculationUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + } + + private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second) + { + var firstBase = (OsuHitObject)first.BaseObject; + var secondBase = (OsuHitObject)second.BaseObject; + double objectRadius = firstBase.Radius; + + double distance = Vector2.Distance(firstBase.StackedPosition, secondBase.StackedPosition); + return Math.Clamp(1 - Math.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs new file mode 100644 index 000000000000..a373df706cd5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -0,0 +1,209 @@ +// 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.Extensions.ObjectExtensions; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim +{ + public static class SnapAimEvaluator + { + private const double wide_angle_multiplier = 1.05; + private const double acute_angle_multiplier = 2.41; + private const double slider_multiplier = 1.5; + private const double velocity_change_multiplier = 0.9; + private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation + private const double maximum_repetition_nerf = 0.15; + private const double maximum_vector_influence = 0.5; + + /// + /// Evaluates the difficulty of aiming the current object, based on: + /// + /// cursor velocity to the current object, + /// angle difficulty, + /// sharp velocity increases, + /// and slider difficulty. + /// + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) + { + if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + return 0; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); + var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2); + + const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; + const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; + + // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. + double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance; + double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + + // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. + if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) + { + double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; + currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime); + } + + double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; + double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; + + double aimStrain = currVelocity; // Start strain with regular velocity. + + // Penalize angle repetition. + aimStrain *= vectorAngleRepetition(osuCurrObj, osuLastObj); + + if (osuCurrObj.Angle != null && osuLastObj.Angle != null) + { + double currAngle = osuCurrObj.Angle.Value; + double lastAngle = osuLastObj.Angle.Value; + + // Rewarding angles, take the smaller velocity as base. + double velocityInfluence = Math.Min(currVelocity, prevVelocity); + + double acuteAngleBonus = 0; + + if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. + { + acuteAngleBonus = CalcAngleAcuteness(currAngle); + + // Penalize angle repetition. It is important to do it _before_ multiplying by anything because we compare raw acuteness here + acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAngleAcuteness(lastAngle), 3))); + + // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter + acuteAngleBonus *= velocityInfluence * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * + DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2); + } + + double wideAngleBonus = calcAngleWideness(currAngle); + + // Penalize angle repetition. It is important to do it _before_ multiplying by velocity because we compare raw wideness here + wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcAngleWideness(lastAngle), 3))); + + wideAngleBonus *= velocityInfluence; + + if (osuLast2Obj != null) + { + // If objects just go back and forth through a middle point - don't give as much wide bonus + // Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object + var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject; + var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject; + + float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length; + + if (distance < 1) + { + wideAngleBonus *= 1 - 0.55 * (1 - distance); + } + } + + // Add in acute angle bonus or wide angle bonus, whichever is larger. + aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); + + // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle + // https://www.desmos.com/calculator/dp0v0nvowc + double wiggleBonus = velocityInfluence + * DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) + * DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + + aimStrain += wiggleBonus * wiggle_multiplier; + } + + if (Math.Max(prevVelocity, currVelocity) != 0) + { + if (withSliderTravelDistance) + { + // We want to use just the object jump without slider velocity when awarding differences + currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + } + + // Scale with ratio of difference compared to 0.5 * max dist. + double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); + + // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. + double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); + + double velocityChangeBonus = overlapVelocityBuff * distRatio; + + // Penalize for rhythm changes. + velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); + + aimStrain += velocityChangeBonus * velocity_change_multiplier; + } + + // Reward sliders based on velocity. + if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) + { + double sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + aimStrain += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier; + } + + // Apply high circle size bonus + aimStrain *= osuCurrObj.SmallCircleBonus; + + aimStrain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + + return aimStrain; + } + + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65))); + + private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuDifficultyHitObject previous) + { + if (current.Angle == null || previous.Angle == null) + return 1; + + const double note_limit = 6; + + double constantAngleCount = 0; + + for (int index = 0; index < note_limit; index++) + { + var loopObj = (OsuDifficultyHitObject)current.Previous(index); + + if (loopObj.IsNull()) + break; + + // Only consider vectors in the same jump section, stopping to change rhythm ruins momentum + if (Math.Max(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime) > 1.1 * Math.Min(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime)) + break; + + if (loopObj.NormalisedVectorAngle.IsNotNull() && current.NormalisedVectorAngle.IsNotNull()) + { + double angleDifference = Math.Abs(current.NormalisedVectorAngle.Value - loopObj.NormalisedVectorAngle.Value); + // Refer to this desmos for tuning, constants need to be precise so that values stay within the range of 0 and 1. + // https://www.desmos.com/calculator/a8jesv5sv2 + constantAngleCount += Math.Cos(8 * Math.Min(double.DegreesToRadians(11.25), angleDifference)); + } + } + + double vectorRepetition = Math.Pow(Math.Min(0.5 / constantAngleCount, 1), 2); + + double stackFactor = DifficultyCalculationUtils.Smootherstep(current.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_DIAMETER); + + double currAngle = current.Angle.Value; + double lastAngle = previous.Angle.Value; + + double angleDifferenceAdjusted = Math.Cos(2 * Math.Min(double.DegreesToRadians(45), Math.Abs(currAngle - lastAngle) * stackFactor)); + + double baseNerf = 1 - maximum_repetition_nerf * CalcAngleAcuteness(lastAngle) * angleDifferenceAdjusted; + + return Math.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2); + } + + private static double calcAngleWideness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); + + public static double CalcAngleAcuteness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs deleted file mode 100644 index dcf8ac0fedbd..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Utils; -using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using osu.Game.Rulesets.Osu.Objects; - -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators -{ - public static class AimEvaluator - { - private const double wide_angle_multiplier = 1.5; - private const double acute_angle_multiplier = 2.55; - private const double slider_multiplier = 1.35; - private const double velocity_change_multiplier = 0.75; - private const double wiggle_multiplier = 1.02; - - /// - /// Evaluates the difficulty of aiming the current object, based on: - /// - /// cursor velocity to the current object, - /// angle difficulty, - /// sharp velocity increases, - /// and slider difficulty. - /// - /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) - { - if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) - return 0; - - var osuCurrObj = (OsuDifficultyHitObject)current; - var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); - var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); - var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2); - - const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; - const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; - - // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. - double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.AdjustedDeltaTime; - - // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. - if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) - { - double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end. - double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object - - currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity. - } - - // As above, do the same for the previous hitobject. - double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime; - - if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) - { - double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime; - double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; - - prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity); - } - - double wideAngleBonus = 0; - double acuteAngleBonus = 0; - double sliderBonus = 0; - double velocityChangeBonus = 0; - double wiggleBonus = 0; - - double aimStrain = currVelocity; // Start strain with regular velocity. - - if (osuCurrObj.Angle != null && osuLastObj.Angle != null) - { - double currAngle = osuCurrObj.Angle.Value; - double lastAngle = osuLastObj.Angle.Value; - - // Rewarding angles, take the smaller velocity as base. - double angleBonus = Math.Min(currVelocity, prevVelocity); - - if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. - { - acuteAngleBonus = calcAcuteAngleBonus(currAngle); - - // Penalize angle repetition. - acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3))); - - // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter - acuteAngleBonus *= angleBonus * - DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * - DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2); - } - - wideAngleBonus = calcWideAngleBonus(currAngle); - - // Penalize angle repetition. - wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); - - // Apply full wide angle bonus for distance more than one diameter - wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter); - - // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle - // https://www.desmos.com/calculator/dp0v0nvowc - wiggleBonus = angleBonus - * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) - * DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); - - if (osuLast2Obj != null) - { - // If objects just go back and forth through a middle point - don't give as much wide bonus - // Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object - var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject; - var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject; - - float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length; - - if (distance < 1) - { - wideAngleBonus *= 1 - 0.35 * (1 - distance); - } - } - } - - if (Math.Max(prevVelocity, currVelocity) != 0) - { - // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. - prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime; - currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime; - - // Scale with ratio of difference compared to 0.5 * max dist. - double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); - - // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. - double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); - - velocityChangeBonus = overlapVelocityBuff * distRatio; - - // Penalize for rhythm changes. - velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); - } - - if (osuLastObj.BaseObject is Slider) - { - // Reward sliders based on velocity. - sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime; - } - - aimStrain += wiggleBonus * wiggle_multiplier; - aimStrain += velocityChangeBonus * velocity_change_multiplier; - - // Add in acute angle bonus or wide angle bonus, whichever is larger. - aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); - - // Apply high circle size bonus - aimStrain *= osuCurrObj.SmallCircleBonus; - - // Add in additional slider velocity bonus. - if (withSliderTravelDistance) - aimStrain += sliderBonus * slider_multiplier; - - return aimStrain; - } - - private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); - - private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); - } -} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index 55192df7af93..e828eba1cc61 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -2,8 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators @@ -28,7 +32,7 @@ public static class FlashlightEvaluator /// and whether the hidden mod is enabled. /// /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden) + public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList mods) { if (current.BaseObject is Spinner) return 0; @@ -66,7 +70,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0); // Bonus based on how visible the object is. - double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, hidden)); + double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, mods.OfType().Any(m => !m.OnlyFadeApproachCircles.Value))); result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; @@ -84,7 +88,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd result = Math.Pow(smallDistNerf * result, 2.0); // Additional bonus for Hidden due to there being no approach circles. - if (hidden) + if (mods.OfType().Any()) result *= 1.0 + hidden_bonus; // Nerf patterns with repeated angles. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs new file mode 100644 index 000000000000..fe6d49661b9c --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -0,0 +1,272 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + public static class ReadingEvaluator + { + private const double reading_window_size = 3000; // 3 seconds + private const double distance_influence_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.5; // 1.5 circles distance between centers + private const double hidden_multiplier = 0.28; + private const double density_multiplier = 2.4; + private const double density_difficulty_base = 2.5; + private const double preempt_balancing_factor = 140000; + private const double preempt_starting_point = 500; // AR 9.66 in milliseconds + private const double minimum_angle_relevancy_time = 2000; // 2 seconds + private const double maximum_angle_relevancy_time = 200; + + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden) + { + if (current.BaseObject is Spinner || current.Index == 0) + return 0; + + var currObj = (OsuDifficultyHitObject)current; + var nextObj = (OsuDifficultyHitObject)current.Next(0); + + double velocity = Math.Max(1, currObj.LazyJumpDistance / currObj.AdjustedDeltaTime); // Only allow velocity to buff + + double currentVisibleObjectDensity = retrieveCurrentVisibleObjectDensity(currObj); + double pastObjectDifficultyInfluence = getPastObjectDifficultyInfluence(currObj); + + double constantAngleNerfFactor = getConstantAngleNerfFactor(currObj); + + double noteDensityDifficulty = calculateDensityDifficulty(nextObj, velocity, constantAngleNerfFactor, pastObjectDifficultyInfluence, currentVisibleObjectDensity); + + double hiddenDifficulty = hidden + ? calculateHiddenDifficulty(currObj, pastObjectDifficultyInfluence, currentVisibleObjectDensity, velocity, constantAngleNerfFactor) + : 0; + + double preemptDifficulty = calculatePreemptDifficulty(velocity, constantAngleNerfFactor, currObj.Preempt); + + double difficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty); + + // Having less time to process information is harder + difficulty *= highBpmBonus(currObj.AdjustedDeltaTime); + + return difficulty; + } + + /// + /// Calculates the density difficulty of the current object and how hard it is to aim it because of it based on: + /// + /// cursor velocity to the current object, + /// how many times the current object's angle was repeated, + /// density of objects visible when the current object appears, + /// density of objects visible when the current object needs to be clicked, + /// /// + /// + private static double calculateDensityDifficulty(OsuDifficultyHitObject? nextObj, double velocity, double constantAngleNerfFactor, + double pastObjectDifficultyInfluence, double currentVisibleObjectDensity) + { + // Consider future densities too because it can make the path the cursor takes less clear + double futureObjectDifficultyInfluence = Math.Sqrt(currentVisibleObjectDensity); + + if (nextObj != null) + { + // Reduce difficulty if movement to next object is small + futureObjectDifficultyInfluence *= DifficultyCalculationUtils.Smootherstep(nextObj.LazyJumpDistance, 15, distance_influence_threshold); + } + + // Value higher note densities exponentially + double noteDensityDifficulty = Math.Pow(pastObjectDifficultyInfluence + futureObjectDifficultyInfluence, 1.7) * 0.4 * constantAngleNerfFactor * velocity; + + // Award only denser than average maps. + noteDensityDifficulty = Math.Max(0, noteDensityDifficulty - density_difficulty_base); + + // Apply a soft cap to general density reading to account for partial memorization + noteDensityDifficulty = Math.Pow(noteDensityDifficulty, 0.45) * density_multiplier; + + return noteDensityDifficulty; + } + + /// + /// Calculates the difficulty of aiming the current object when the approach rate is very high based on: + /// + /// cursor velocity to the current object, + /// how many times the current object's angle was repeated, + /// how many milliseconds elapse between the approach circle appearing and touching the inner circle + /// + /// + private static double calculatePreemptDifficulty(double velocity, double constantAngleNerfFactor, double preempt) + { + // Arbitrary curve for the base value preempt difficulty should have as approach rate increases. + // https://www.desmos.com/calculator/c175335a71 + double preemptDifficulty = Math.Pow((preempt_starting_point - preempt + Math.Abs(preempt - preempt_starting_point)) / 2, 2.5) / preempt_balancing_factor; + + preemptDifficulty *= constantAngleNerfFactor * velocity; + + return preemptDifficulty; + } + + /// + /// Calculates the difficulty of aiming the current object when the hidden mod is active based on: + /// + /// cursor velocity to the current object, + /// time the current object spends invisible, + /// density of objects visible when the current object appears, + /// density of objects visible when the current object needs to be clicked, + /// how many times the current object's angle was repeated, + /// if the current object is perfectly stacked to the previous one + /// + /// + private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, double pastObjectDifficultyInfluence, double currentVisibleObjectDensity, double velocity, + double constantAngleNerfFactor) + { + // Higher preempt means that time spent invisible is higher too, we want to reward that + double preemptFactor = Math.Pow(currObj.Preempt, 2.2) * 0.01; + + // Account for both past and current densities + double densityFactor = Math.Pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3; + + double hiddenDifficulty = (preemptFactor + densityFactor) * constantAngleNerfFactor * velocity * 0.01; + + // Apply a soft cap to general HD reading to account for partial memorization + hiddenDifficulty = Math.Pow(hiddenDifficulty, 0.4) * hidden_multiplier; + + var previousObj = (OsuDifficultyHitObject)currObj.Previous(0); + + // Buff perfect stacks only if current note is completely invisible at the time you click the previous note. + if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime, true) == 0 && previousObj.StartTime > currObj.StartTime - currObj.Preempt) + hiddenDifficulty += hidden_multiplier * 2500 / Math.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes + + return hiddenDifficulty; + } + + private static double getPastObjectDifficultyInfluence(OsuDifficultyHitObject currObj) + { + double pastObjectDifficultyInfluence = 0; + + foreach (var loopObj in retrievePastVisibleObjects(currObj)) + { + double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false); + + // When aiming an object small distances mean previous objects may be cheesed, so it doesn't matter whether they were arranged confusingly. + loopDifficulty *= DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 15, distance_influence_threshold); + + // Account less for objects close to the max reading window + double timeBetweenCurrAndLoopObj = currObj.StartTime - loopObj.StartTime; + double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj); + + loopDifficulty *= timeNerfFactor; + pastObjectDifficultyInfluence += loopDifficulty; + } + + return pastObjectDifficultyInfluence; + } + + // Returns a list of objects that are visible on screen at the point in time the current object becomes visible. + private static IEnumerable retrievePastVisibleObjects(OsuDifficultyHitObject current) + { + for (int i = 0; i < current.Index; i++) + { + OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i); + + if (hitObject.IsNull() || + current.StartTime - hitObject.StartTime > reading_window_size || + hitObject.StartTime < current.StartTime - current.Preempt) // Current object not visible at the time object needs to be clicked + break; + + yield return hitObject; + } + } + + // Returns the density of objects visible at the point in time the current object needs to be clicked capped by the reading window. + private static double retrieveCurrentVisibleObjectDensity(OsuDifficultyHitObject current) + { + double visibleObjectCount = 0; + + OsuDifficultyHitObject? hitObject = (OsuDifficultyHitObject)current.Next(0); + + while (hitObject != null) + { + if (hitObject.StartTime - current.StartTime > reading_window_size || + current.StartTime < hitObject.StartTime - hitObject.Preempt) // Object not visible at the time current object needs to be clicked. + break; + + double timeBetweenCurrAndLoopObj = hitObject.StartTime - current.StartTime; + double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj); + + visibleObjectCount += hitObject.OpacityAt(current.BaseObject.StartTime, false) * timeNerfFactor; + + hitObject = (OsuDifficultyHitObject?)hitObject.Next(0); + } + + return visibleObjectCount; + } + + // Returns a factor of how often the current object's angle has been repeated in a certain time frame. + // It does this by checking the difference in angle between current and past objects and sums them based on a range of similarity. + // https://www.desmos.com/calculator/eb057a4822 + private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) + { + double constantAngleCount = 0; + int index = 0; + double currentTimeGap = 0; + + OsuDifficultyHitObject loopObjPrev0 = current; + OsuDifficultyHitObject? loopObjPrev1 = null; + OsuDifficultyHitObject? loopObjPrev2 = null; + + while (currentTimeGap < minimum_angle_relevancy_time) + { + var loopObj = (OsuDifficultyHitObject)current.Previous(index); + + if (loopObj.IsNull()) + break; + + // Account less for objects that are close to the time limit. + double longIntervalFactor = 1 - DifficultyCalculationUtils.ReverseLerp(loopObj.AdjustedDeltaTime, maximum_angle_relevancy_time, minimum_angle_relevancy_time); + + if (loopObj.Angle.IsNotNull() && current.Angle.IsNotNull()) + { + double angleDifference = Math.Abs(current.Angle.Value - loopObj.Angle.Value); + double angleDifferenceAlternating = Math.PI; + + if (loopObjPrev0.Angle != null && loopObjPrev1?.Angle != null && loopObjPrev2?.Angle != null) + { + angleDifferenceAlternating = Math.Abs(loopObjPrev1.Angle.Value - loopObj.Angle.Value); + angleDifferenceAlternating += Math.Abs(loopObjPrev2.Angle.Value - loopObjPrev0.Angle.Value); + + double weight = 1.0; + + // Be sure that one of the angles is very sharp, when other is wide + weight *= DifficultyCalculationUtils.ReverseLerp(Math.Min(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 20, 5); + weight *= DifficultyCalculationUtils.ReverseLerp(Math.Max(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 60, 120); + + // Lerp between max angle difference and rescaled alternating difference, with more harsh scaling compared to normal difference + angleDifferenceAlternating = double.Lerp(Math.PI, 0.1 * angleDifferenceAlternating, weight); + } + + double stackFactor = DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + + constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), Math.Min(angleDifference, angleDifferenceAlternating) * stackFactor)) * longIntervalFactor; + } + + currentTimeGap = current.StartTime - loopObj.StartTime; + index++; + + loopObjPrev2 = loopObjPrev1; + loopObjPrev1 = loopObjPrev0; + loopObjPrev0 = loopObj; + } + + return Math.Clamp(2 / constantAngleCount, 0.2, 1); + } + + // Returns a nerfing factor for when objects are very distant in time, affecting reading less. + private static double getTimeNerfFactor(double deltaTime) + { + return Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1); + } + + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.8, ms / 1000)); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs similarity index 77% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs index 9349083951e1..e2ee41162b41 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -8,15 +8,16 @@ using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed { public static class RhythmEvaluator { private const int history_time_max = 5 * 1000; // 5 seconds private const int history_objects_max = 32; - private const double rhythm_overall_multiplier = 1.0; - private const double rhythm_ratio_multiplier = 15.0; + private const double rhythm_overall_multiplier = 0.8; + private const double rhythm_ratio_multiplier = 32.0; /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . @@ -26,11 +27,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (current.BaseObject is Spinner) return 0; - var currentOsuObject = (OsuDifficultyHitObject)current; - double rhythmComplexitySum = 0; - double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; + double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindow(HitResult.Great) * 0.3; var island = new Island(deltaDifferenceEpsilon); var previousIsland = new Island(deltaDifferenceEpsilon); @@ -57,6 +56,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) for (int i = rhythmStart; i > 0; i--) { OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1); + if (currObj.BaseObject is Spinner) + continue; // scales note 0 to 1 from history to now double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; @@ -64,7 +65,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. - // Use custom cap value to ensure that that at this point delta time is actually zero + // Use custom cap value to ensure that at this point delta time is actually zero double currDelta = Math.Max(currObj.DeltaTime, 1e-7); double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7); double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7); @@ -73,17 +74,27 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta); - // Take only the fractional part of the value since we're only interested in punishing multiples - double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference); - - double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction)); - // reduce ratio bonus if delta difference is too big double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0); double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); - double effectiveRatio = windowPenalty * currRatio * differenceMultiplier; + double effectiveRatio = getEffectiveRatio(deltaDifference) * windowPenalty * differenceMultiplier; + + // if previous object is a slider it might be easier to tap since you don't have to do a whole tapping motion + // while a full deltatime might end up some weird ratio the "unpress->tap" motion might be simple + // for example a slider-circle-circle pattern should be evaluated as a regular triple and not as a single->double + if (prevObj.BaseObject is Slider) + { + double sliderLazyEndDelta = currObj.MinimumJumpTime; + double sliderLazyDeltaDifference = Math.Max(sliderLazyEndDelta, currDelta) / Math.Min(sliderLazyEndDelta, currDelta); + + double sliderRealEndDelta = currObj.LastObjectEndDeltaTime; + double sliderRealDeltaDifference = Math.Max(sliderRealEndDelta, currDelta) / Math.Min(sliderRealEndDelta, currDelta); + + double sliderEffectiveRatio = Math.Min(getEffectiveRatio(sliderLazyDeltaDifference), getEffectiveRatio(sliderRealDeltaDifference)); + effectiveRatio = Math.Min(sliderEffectiveRatio, effectiveRatio); + } if (firstDeltaSwitch) { @@ -96,12 +107,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) { // bpm change is into slider, this is easy acc window if (currObj.BaseObject is Slider) - effectiveRatio *= 0.125; - - // bpm change was from a slider, this is easier typically than circle -> circle - // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders - if (prevObj.BaseObject is Slider) - effectiveRatio *= 0.3; + effectiveRatio *= 0.5; // repeated island polarity (2 -> 4, 3 -> 5) if (island.IsSimilarPolarity(previousIsland)) @@ -176,10 +182,15 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) prevObj = currObj; } - double rhythmDifficulty = Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) - rhythmDifficulty *= 1 - currentOsuObject.GetDoubletapness((OsuDifficultyHitObject)current.Next(0)); + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though); + } + + private static double getEffectiveRatio(double deltaDifference) + { + // Take only the fractional part of the value since we're only interested in punishing multiples + double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference); - return rhythmDifficulty; + return 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction)); } private class Island : IEquatable @@ -211,9 +222,12 @@ public void AddDelta(int delta) public bool IsSimilarPolarity(Island other) { - // TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) - // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation - return DeltaCount % 2 == other.DeltaCount % 2; + // single delta islands shouldn't be compared + if (DeltaCount <= 1 || other.DeltaCount <= 1) + return false; + + return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon && + DeltaCount % 2 == other.DeltaCount % 2; } public bool Equals(Island? other) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs similarity index 58% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs index a58c1d36853e..65aab9e4bd64 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs @@ -2,47 +2,39 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; -using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed { public static class SpeedEvaluator { - private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers private const double min_speed_bonus = 200; // 200 BPM 1/4th private const double speed_balancing_factor = 40; - private const double distance_multiplier = 0.8; /// /// Evaluates the difficulty of tapping the current object, based on: /// /// time between pressing the previous and current object, - /// distance between those objects, /// and how easily they can be cheesed. /// /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList mods) + public static double EvaluateDifficultyOf(DifficultyHitObject current) { if (current.BaseObject is Spinner) return 0; - // derive strainTime for calculation var osuCurrObj = (OsuDifficultyHitObject)current; - var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; double strainTime = osuCurrObj.AdjustedDeltaTime; double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); // Cap deltatime to the OD 300 hitwindow. // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. - strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); + strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindow(HitResult.Great)) / 0.93, 0.92, 1); // speedBonus will be 0.0 for BPM < 200 double speedBonus = 0.0; @@ -51,26 +43,15 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); - double travelDistance = osuPrevObj?.TravelDistance ?? 0; - double distance = travelDistance + osuCurrObj.MinimumJumpDistance; - - // Cap distance at single_spacing_threshold - distance = Math.Min(distance, single_spacing_threshold); - - // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold - double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier; - - // Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps - distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus); - - if (mods.OfType().Any()) - distanceBonus = 0; - // Base difficulty with all bonuses - double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime; + double difficulty = (1 + speedBonus) * 1000 / strainTime; + + difficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); // Apply penalty if there's doubletappable doubles return difficulty * doubletapness; } + + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 9cab45414266..8384093e5035 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -45,6 +45,12 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("flashlight_difficulty")] public double FlashlightDifficulty { get; set; } + /// + /// The difficulty corresponding to the reading skill. + /// + [JsonProperty("reading_difficulty")] + public double ReadingDifficulty { get; set; } + /// /// Describes how much of is contributed to by hitcircles or sliders. /// A value closer to 1.0 indicates most of is contributed by hitcircles. @@ -54,9 +60,9 @@ public class OsuDifficultyAttributes : DifficultyAttributes public double SliderFactor { get; set; } /// - /// Describes how much of is contributed to by hitcircles or sliders - /// A value closer to 0.0 indicates most of is contributed by hitcircles - /// A value closer to Infinity indicates most of is contributed by sliders + /// Describes how much of the highest aim difficulties are hitcircles or sliders + /// A value closer to 0.0 indicates most of the highest aim difficulties are hitcircles + /// A value closer to Infinity indicates most of the highest aim difficulties are sliders /// [JsonProperty("aim_top_weighted_slider_factor")] public double AimTopWeightedSliderFactor { get; set; } @@ -69,12 +75,21 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("speed_top_weighted_slider_factor")] public double SpeedTopWeightedSliderFactor { get; set; } - [JsonProperty("aim_difficult_strain_count")] - public double AimDifficultStrainCount { get; set; } + [JsonProperty("aim_penalty_coefficient_a")] + public double AimMissPenaltyCoefficientA { get; set; } + + [JsonProperty("aim_penalty_coefficient_b")] + public double AimMissPenaltyCoefficientB { get; set; } + + [JsonProperty("aim_penalty_coefficient_c")] + public double AimMissPenaltyCoefficientC { get; set; } [JsonProperty("speed_difficult_strain_count")] public double SpeedDifficultStrainCount { get; set; } + [JsonProperty("reading_difficult_note_count")] + public double ReadingDifficultNoteCount { get; set; } + [JsonProperty("nested_score_per_object")] public double NestedScorePerObject { get; set; } @@ -84,11 +99,6 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("maximum_legacy_combo_score")] public double MaximumLegacyComboScore { get; set; } - /// - /// The beatmap's drain rate. This doesn't scale with rate-adjusting mods. - /// - public double DrainRate { get; set; } - /// /// The number of hitcircles in the beatmap. /// @@ -111,6 +121,7 @@ public class OsuDifficultyAttributes : DifficultyAttributes yield return (ATTRIB_ID_AIM, AimDifficulty); yield return (ATTRIB_ID_SPEED, SpeedDifficulty); + yield return (ATTRIB_ID_READING, ReadingDifficulty); yield return (ATTRIB_ID_DIFFICULTY, StarRating); if (ShouldSerializeFlashlightDifficulty()) @@ -118,7 +129,6 @@ public class OsuDifficultyAttributes : DifficultyAttributes yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor); - yield return (ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT, AimDifficultStrainCount); yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount); yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount); yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount); @@ -127,6 +137,7 @@ public class OsuDifficultyAttributes : DifficultyAttributes yield return (ATTRIB_ID_NESTED_SCORE_PER_OBJECT, NestedScorePerObject); yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier); yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore); + yield return (ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT, ReadingDifficultNoteCount); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -135,10 +146,10 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary val AimDifficulty = values[ATTRIB_ID_AIM]; SpeedDifficulty = values[ATTRIB_ID_SPEED]; + ReadingDifficulty = values[ATTRIB_ID_READING]; StarRating = values[ATTRIB_ID_DIFFICULTY]; FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; - AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT]; SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT]; SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT]; AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT]; @@ -147,7 +158,7 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary val NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT]; LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER]; MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE]; - DrainRate = onlineInfo.DrainRate; + ReadingDifficultNoteCount = values[ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT]; HitCircleCount = onlineInfo.CircleCount; SliderCount = onlineInfo.SliderCount; SpinnerCount = onlineInfo.SpinnerCount; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 504fddbb711a..1d82b2f007b8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -8,6 +8,7 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Skills; @@ -16,13 +17,12 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { - private const double star_rating_multiplier = 0.0265; - public override int Version => 20251020; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) @@ -46,7 +46,7 @@ public static double CalculateRateAdjustedOverallDifficulty(double overallDiffic return (79.5 - hitWindowGreat) / 6; } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new OsuDifficultyAttributes { Mods = mods }; @@ -55,24 +55,30 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); var speed = skills.OfType().Single(); var flashlight = skills.OfType().SingleOrDefault(); + var reading = skills.OfType().Single(); - double speedNotes = speed.RelevantNoteCount(); + double aimDifficultyValue = aim.DifficultyValue(); + double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); + double speedDifficultyValue = speed.DifficultyValue(); + double readingDifficultyValue = reading.DifficultyValue(); + + double[] aimMissPenaltyCoefficients = aim.GetMissPenaltyCoefficients(); + double speedDifficultStrainCount = speed.CountTopWeightedObjectDifficulties(speedDifficultyValue); + double readingDifficultNoteCount = reading.CountTopWeightedObjectDifficulties(readingDifficultyValue); - double aimDifficultStrainCount = aim.CountTopWeightedStrains(); - double speedDifficultStrainCount = speed.CountTopWeightedStrains(); + double speedNotes = speed.RelevantNoteCount(); - double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(); - double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(); + double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(aimNoSlidersDifficultyValue); + double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(aimNoSlidersDifficultyValue); double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount); - double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(); + double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(speedDifficultyValue); double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount); double difficultSliders = aim.GetDifficultSliders(); - double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate); - double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate); + double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, ModUtils.CalculateRateWithMods(mods)); int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); int sliderCount = beatmap.HitObjects.Count(h => h is Slider); @@ -80,19 +86,15 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat int totalHits = beatmap.HitObjects.Count; - double drainRate = beatmap.Difficulty.DrainRate; + double sliderFactor = aimDifficultyValue > 0 + ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) + : 1; - double aimDifficultyValue = aim.DifficultyValue(); - double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); - double speedDifficultyValue = speed.DifficultyValue(); - - double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue); - double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1; - - var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor); + var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, overallDifficulty); double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue); double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue); + double readingRating = osuRatingCalculator.ComputeReadingRating(readingDifficultyValue); double flashlightRating = 0.0; @@ -100,21 +102,18 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue()); double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); - double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); + double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(WorkingBeatmap.Beatmap); var simulator = new OsuLegacyScoreSimulator(); var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); - double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); - double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); + double baseAimPerformance = TimeSkill.DifficultyToPerformance(aimRating); + double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating); + double baseReadingPerformance = HarmonicSkill.DifficultyToPerformance(readingRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); + double baseCognitionPerformance = SumCognitionDifficulty(baseReadingPerformance, baseFlashlightPerformance); - double basePerformance = - Math.Pow( - Math.Pow(baseAimPerformance, 1.1) + - Math.Pow(baseSpeedPerformance, 1.1) + - Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 - ); + double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance); double starRating = calculateStarRating(basePerformance); @@ -127,12 +126,15 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat SpeedDifficulty = speedRating, SpeedNoteCount = speedNotes, FlashlightDifficulty = flashlightRating, + ReadingDifficulty = readingRating, SliderFactor = sliderFactor, - AimDifficultStrainCount = aimDifficultStrainCount, + AimMissPenaltyCoefficientA = aimMissPenaltyCoefficients.ElementAtOrDefault(0), + AimMissPenaltyCoefficientB = aimMissPenaltyCoefficients.ElementAtOrDefault(1), + AimMissPenaltyCoefficientC = aimMissPenaltyCoefficients.ElementAtOrDefault(2), SpeedDifficultStrainCount = speedDifficultStrainCount, + ReadingDifficultNoteCount = readingDifficultNoteCount, AimTopWeightedSliderFactor = aimTopWeightedSliderFactor, SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor, - DrainRate = drainRate, MaxCombo = beatmap.GetMaxCombo(), HitCircleCount = hitCircleCount, SliderCount = sliderCount, @@ -145,28 +147,29 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat return attributes; } - private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) + public static double SumCognitionDifficulty(double reading, double flashlight) { - double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue)); - double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue)); + if (reading <= 0) + return flashlight; - double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1); + if (flashlight <= 0) + return reading; - return calculateStarRating(totalValue); + // Nerf flashlight value in cognition sum when reading is greater than flashlight + return DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0)); } private double calculateStarRating(double basePerformance) { - if (basePerformance <= 0.00001) - return 0; - - return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4); + return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { List objects = new List(); + double clockRate = ModUtils.CalculateRateWithMods(mods); + // The first jump is formed by the first two hitobjects of the map. // If the map has less than two OsuHitObjects, the enumerator will not return anything. for (int i = 1; i < beatmap.HitObjects.Count; i++) @@ -177,13 +180,14 @@ protected override IEnumerable CreateDifficultyHitObjects(I return objects; } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { var skills = new List { new Aim(mods, true), new Aim(mods, false), - new Speed(mods) + new Speed(mods), + new Reading(mods) }; if (mods.Any(h => h is OsuModFlashlight)) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs index 0d406ea72a60..8bde33d292c1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs @@ -115,9 +115,13 @@ private double calculateMaximumComboBasedMissCount() double missCount = 0; + // If sliders in the map are hard - it's likely for player to drop sliderends + // If map has easy sliders - it's more likely for player to sliderbreak + double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; + // In classic scores we can't know the amount of dropped sliders so we estimate it + double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount); if (score.MaxCombo < fullComboThreshold) missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index 8577eff11ff5..e4a64cd81d6b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -21,6 +21,9 @@ public class OsuPerformanceAttributes : PerformanceAttributes [JsonProperty("flashlight")] public double Flashlight { get; set; } + [JsonProperty("reading")] + public double Reading { get; set; } + [JsonProperty("effective_miss_count")] public double EffectiveMissCount { get; set; } @@ -48,6 +51,7 @@ public override IEnumerable GetAttributesForDisplay yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed); yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy); yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight); + yield return new PerformanceDisplayAttribute(nameof(Reading), "Reading", Reading); } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 741ddb3d4fdd..6776fe1d06ee 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -9,6 +9,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; @@ -19,7 +20,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { - public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + public const double PERFORMANCE_BASE_MULTIPLIER = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + public const double PERFORMANCE_NORM_EXPONENT = 1.1; private bool usingClassicSliderAccuracy; private bool usingScoreV2; @@ -50,14 +52,18 @@ public class OsuPerformanceCalculator : PerformanceCalculator private double greatHitWindow; private double okHitWindow; private double mehHitWindow; + private double overallDifficulty; private double approachRate; + private double drainRate; private double? speedDeviation; private double aimEstimatedSliderBreaks; private double speedEstimatedSliderBreaks; + public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); + public OsuPerformanceCalculator() : base(new OsuRuleset()) { @@ -95,11 +101,12 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate); overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate); + drainRate = difficulty.DrainRate; double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); double? scoreBasedEstimatedMissCount = null; - if (usingClassicSliderAccuracy && score.LegacyTotalScore != null) + if (usingClassicSliderAccuracy && !usingScoreV2 && score.LegacyTotalScore != null) { var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes); scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate(); @@ -140,15 +147,12 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s double aimValue = computeAimValue(score, osuAttributes); double speedValue = computeSpeedValue(score, osuAttributes); double accuracyValue = computeAccuracyValue(score, osuAttributes); + + double readingValue = computeReadingValue(osuAttributes); double flashlightValue = computeFlashlightValue(score, osuAttributes); + double cognitionValue = OsuDifficultyCalculator.SumCognitionDifficulty(readingValue, flashlightValue); - double totalValue = - Math.Pow( - Math.Pow(aimValue, 1.1) + - Math.Pow(speedValue, 1.1) + - Math.Pow(accuracyValue, 1.1) + - Math.Pow(flashlightValue, 1.1), 1.0 / 1.1 - ) * multiplier; + double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier; return new OsuPerformanceAttributes { @@ -156,6 +160,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s Speed = speedValue, Accuracy = accuracyValue, Flashlight = flashlightValue, + Reading = readingValue, EffectiveMissCount = effectiveMissCount, ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount, ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount, @@ -194,11 +199,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut aimDifficulty *= sliderNerfFactor; } - double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty); - - double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - aimValue *= lengthBonus; + double aimValue = DifficultyToPerformance(aimDifficulty); if (effectiveMissCount > 0) { @@ -206,15 +207,25 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); - aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount); + double[] coefficients = + [ + attributes.AimMissPenaltyCoefficientA, + attributes.AimMissPenaltyCoefficientB, + attributes.AimMissPenaltyCoefficientC, + // We can derive the 4th coefficient from the first third, since at x = 1 our polynomial is equal to the sum of the coefficients, + // and the relevant miss count there is log(totalHits - 1) since our polynomial uses log miss counts. + Math.Log(totalHits + 1) - attributes.AimMissPenaltyCoefficientA - attributes.AimMissPenaltyCoefficientB - attributes.AimMissPenaltyCoefficientC + ]; + + aimValue *= calculatePolynomialMissPenalty(relevantMissCount, coefficients); } // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if (score.Mods.Any(m => m is OsuModBlinds)) - aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); + aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate); else if (score.Mods.Any(m => m is OsuModTraceable)) { - aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor); + aimValue *= 1.0 + calculateTraceableBonus(attributes.SliderFactor); } aimValue *= accuracy; @@ -227,11 +238,7 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null) return 0.0; - double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); - - double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - speedValue *= lengthBonus; + double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty); if (effectiveMissCount > 0) { @@ -239,32 +246,27 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); - speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount); + speedValue *= calculateStrainCountMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount); } - // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if (score.Mods.Any(m => m is OsuModBlinds)) { // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. speedValue *= 1.12; } - else if (score.Mods.Any(m => m is OsuModTraceable)) - { - speedValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate); - } double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); speedValue *= speedHighDeviationMultiplier; - // Calculate accuracy assuming the worst case scenario - double relevantTotalDiff = Math.Max(0, totalHits - attributes.SpeedNoteCount); - double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff); - double relevantCountOk = Math.Max(0, countOk - Math.Max(0, relevantTotalDiff - countGreat)); - double relevantCountMeh = Math.Max(0, countMeh - Math.Max(0, relevantTotalDiff - countGreat - countOk)); - double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); + // An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window. + // For example, a speed SR of 4.0 leads to an effective hit window of 20ms, which is OD 10. + double effectiveHitWindow = 20 * Math.Pow(4 / attributes.SpeedDifficulty, 0.35); - // Scale the speed value with accuracy and OD. - speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); + // Find the proportion of 300s on speed notes assuming the hit window was the effective hit window. + double effectiveAccuracy = DifficultyCalculationUtils.Erf(effectiveHitWindow / (double)speedDeviation); + + // Scale speed value by normalized accuracy. + speedValue *= Math.Pow(effectiveAccuracy, 2); return speedValue; } @@ -294,12 +296,14 @@ private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes att double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. - accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); + accuracyValue *= amountHitObjectsWithAccuracy < 1000 + ? Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3) + : Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.1); // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. if (score.Mods.Any(m => m is OsuModBlinds)) accuracyValue *= 1.14; - else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) + else if (score.Mods.Any(m => m is OsuModTraceable)) { // Decrease bonus for AR > 10 accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10); @@ -330,6 +334,19 @@ private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes a return flashlightValue; } + private double computeReadingValue(OsuDifficultyAttributes attributes) + { + double readingValue = HarmonicSkill.DifficultyToPerformance(attributes.ReadingDifficulty); + + if (effectiveMissCount > 0) + readingValue *= calculateStrainCountMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.ReadingDifficultNoteCount); + + // Scale the reading value with accuracy _harshly_. + readingValue *= Math.Pow(accuracy, 3); + + return readingValue; + } + private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes) { if (attributes.SliderCount <= 0) @@ -339,9 +356,13 @@ private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes att if (usingClassicSliderAccuracy) { + // If sliders in the map are hard - it's likely for player to drop sliderends + // If map has easy sliders - it's more likely for player to sliderbreak + double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; + // In classic scores we can't know the amount of dropped sliders so we estimate it + double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount); if (scoreMaxCombo < fullComboThreshold) missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); @@ -470,7 +491,7 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute if (speedDeviation == null) return 0; - double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); + double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty); // Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty. // This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value. @@ -489,10 +510,40 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute return adjustedSpeedValue / speedValue; } - // Miss penalty assumes that a player will miss on the hardest parts of a map, - // so we use the amount of relatively difficult sections to adjust miss penalty - // to make it more punishing on maps with lower amount of hard sections. - private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1); + /// + /// Calculates a visibility bonus that is applicable to Traceable. + /// + private double calculateTraceableBonus(double sliderFactor = 1) + { + // We want to reward slider aim less, more so at lower AR + double highApproachRateSliderVisibilityFactor = 0.5 + (Math.Pow(sliderFactor, 6) / 2); + double lowApproachRateSliderVisibilityFactor = Math.Pow(sliderFactor, 6); + + // Start from normal curve, rewarding lower AR up to AR7 + double traceableBonus = 0.0275; + traceableBonus += 0.025 * (12.0 - Math.Max(approachRate, 7)) * highApproachRateSliderVisibilityFactor; + + // For AR up to 0 - reduce reward for very low ARs when object is visible + if (approachRate < 7) + traceableBonus += 0.025 * (7.0 - Math.Max(approachRate, 0)) * lowApproachRateSliderVisibilityFactor; + + // Starting from AR0 - cap values so they won't grow to infinity + if (approachRate < 0) + traceableBonus += 0.025 * (1 - Math.Pow(1.5, approachRate)) * lowApproachRateSliderVisibilityFactor; + + return traceableBonus; + } + + // Due to the unavailability of miss location in PP, the following formulas assume that a player will miss on the hardest parts of a map. + + // With the curve fitted miss penalty, we use a pre-computed curve of skill levels for each miss count, raised to the power of 1.5 as + // the multiple of the exponents on star rating and PP. This power should be changed if either SR or PP begin to use a different exponent. + private double calculatePolynomialMissPenalty(double missCount, double[] coefficients) => Math.Pow(1 - PolynomialPenaltyUtils.GetPenaltyAt(coefficients, Math.Log(missCount + 1)), 1.89); + + // With the strain count miss penalty, we use the amount of relatively difficult sections to adjust the miss penalty, + // to make it more punishing on maps with lower amount of hard sections. This formula is subject to balance. + private double calculateStrainCountMissPenalty(double missCount, double difficultStrainCount) => 0.93 / (missCount / (4 * Math.Log(difficultStrainCount)) + 1); + private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); private int totalHits => countGreat + countOk + countMeh + countMiss; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index 2a050c0920cf..9dc9b1afa430 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -15,19 +15,13 @@ public class OsuRatingCalculator private readonly Mod[] mods; private readonly int totalHits; - private readonly double approachRate; private readonly double overallDifficulty; - private readonly double mechanicalDifficultyRating; - private readonly double sliderFactor; - public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating, double sliderFactor) + public OsuRatingCalculator(Mod[] mods, int totalHits, double overallDifficulty) { this.mods = mods; this.totalHits = totalHits; - this.approachRate = approachRate; this.overallDifficulty = overallDifficulty; - this.mechanicalDifficultyRating = mechanicalDifficultyRating; - this.sliderFactor = sliderFactor; } public double ComputeAimRating(double aimDifficultyValue) @@ -35,13 +29,7 @@ public double ComputeAimRating(double aimDifficultyValue) if (mods.Any(m => m is OsuModAutopilot)) return 0; - double aimRating = CalculateDifficultyRating(aimDifficultyValue); - - if (mods.Any(m => m is OsuModTouchDevice)) - aimRating = Math.Pow(aimRating, 0.8); - - if (mods.Any(m => m is OsuModRelax)) - aimRating *= 0.9; + double aimRating = Math.Pow(aimDifficultyValue, 0.63) * 0.02275; if (mods.Any(m => m is OsuModMagnetised)) { @@ -51,26 +39,6 @@ public double ComputeAimRating(double aimDifficultyValue) double ratingMultiplier = 1.0; - double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - - double approachRateFactor = 0.0; - if (approachRate > 10.33) - approachRateFactor = 0.3 * (approachRate - 10.33); - else if (approachRate < 8.0) - approachRateFactor = 0.05 * (8.0 - approachRate); - - if (mods.Any(h => h is OsuModRelax)) - approachRateFactor = 0.0; - - ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. - - if (mods.Any(m => m is OsuModHidden)) - { - double visibilityFactor = calculateAimVisibilityFactor(approachRate); - ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor, sliderFactor); - } - // It is important to consider accuracy difficulty when scaling with accuracy. ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; @@ -94,29 +62,32 @@ public double ComputeSpeedRating(double speedDifficultyValue) speedRating *= 1.0 - magnetisedStrength * 0.3; } - double ratingMultiplier = 1.0; - - double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); + return speedRating; + } - double approachRateFactor = 0.0; - if (approachRate > 10.33) - approachRateFactor = 0.3 * (approachRate - 10.33); + public double ComputeReadingRating(double readingDifficultyValue) + { + double readingRating = CalculateDifficultyRating(readingDifficultyValue); - if (mods.Any(m => m is OsuModAutopilot)) - approachRateFactor = 0.0; + if (mods.Any(m => m is OsuModTouchDevice)) + readingRating = Math.Pow(readingRating, 0.8); - ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + if (mods.Any(m => m is OsuModRelax)) + readingRating *= 0.6; + else if (mods.Any(m => m is OsuModAutopilot)) + readingRating *= 0.3; - if (mods.Any(m => m is OsuModHidden)) + if (mods.Any(m => m is OsuModMagnetised)) { - double visibilityFactor = calculateSpeedVisibilityFactor(approachRate); - ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor); + float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; + readingRating *= 1.0 - magnetisedStrength; } - ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; + double ratingMultiplier = 1.0; + + ratingMultiplier *= 0.75 + Math.Pow(Math.Max(0, overallDifficulty), 2.2) / 800; - return speedRating * Math.Cbrt(ratingMultiplier); + return readingRating * Math.Cbrt(ratingMultiplier); } public double ComputeFlashlightRating(double flashlightDifficultyValue) @@ -158,56 +129,6 @@ public double ComputeFlashlightRating(double flashlightDifficultyValue) return flashlightRating * Math.Sqrt(ratingMultiplier); } - private double calculateAimVisibilityFactor(double approachRate) - { - const double ar_factor_end_point = 11.5; - - double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10); - double arFactorStartingPoint = double.Lerp(9, 10.33, mechanicalDifficultyFactor); - - return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint); - } - - private double calculateSpeedVisibilityFactor(double approachRate) - { - const double ar_factor_end_point = 11.5; - - double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10); - double arFactorStartingPoint = double.Lerp(10, 10.33, mechanicalDifficultyFactor); - - return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint); - } - - /// - /// Calculates a visibility bonus that is applicable to Hidden and Traceable. - /// - public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 1, double sliderFactor = 1) - { - // NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side. - bool isAlwaysPartiallyVisible = mods.OfType().Any(m => m.OnlyFadeApproachCircles.Value) || mods.OfType().Any(); - - // Start from normal curve, rewarding lower AR up to AR7 - // TC forcefully requires a lower reading bonus for now as it's post-applied in PP which makes it multiplicative with the regular AR bonuses - // This means it has an advantage over HD, so we decrease the multiplier to compensate - // This should be removed once we're able to apply TC bonuses in SR (depends on real-time difficulty calculations being possible) - double readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) * (12.0 - Math.Max(approachRate, 7)); - - readingBonus *= visibilityFactor; - - // We want to reward slideraim on low AR less - double sliderVisibilityFactor = Math.Pow(sliderFactor, 3); - - // For AR up to 0 - reduce reward for very low ARs when object is visible - if (approachRate < 7) - readingBonus += (isAlwaysPartiallyVisible ? 0.02 : 0.045) * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor; - - // Starting from AR0 - cap values so they won't grow to infinity - if (approachRate < 0) - readingBonus += (isAlwaysPartiallyVisible ? 0.01 : 0.1) * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor; - - return readingBonus; - } - public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 5e9fc10ef877..221b2c637852 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -35,6 +35,22 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public readonly double AdjustedDeltaTime; + /// + /// Amount of time elapsed between lastDifficultyObject's and capped to a minimum of ms. + /// + public double LastObjectEndDeltaTime { get; private set; } + + /// + /// Time (in ms) between the object first appearing and the time it needs to be clicked. + /// adjusted by clock rate. + /// + public readonly double Preempt; + + /// + /// Normalised distance from the start position of the previous to the start position of this . + /// + public double JumpDistance { get; private set; } + /// /// Normalised distance from the "lazy" end position of the previous to the start position of this . /// @@ -101,9 +117,10 @@ public class OsuDifficultyHitObject : DifficultyHitObject public double? Angle { get; private set; } /// - /// Retrieves the full hit window for a Great . + /// Angle of the vector created between current and current-1 + /// normalised to consider symmetrical vectors in any axis to be the same angle. /// - public double HitWindowGreat { get; private set; } + public double? NormalisedVectorAngle { get; private set; } /// /// Selective bonus for maps with higher circle size. @@ -121,17 +138,11 @@ public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME); + LastObjectEndDeltaTime = lastDifficultyObject != null ? Math.Max(StartTime - lastDifficultyObject.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime; - SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); + SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 70); - if (BaseObject is Slider sliderObject) - { - HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate; - } - else - { - HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate; - } + Preempt = BaseObject.TimePreempt / clockRate; computeSliderCursorPosition(); setDistances(clockRate); @@ -148,7 +159,9 @@ public double OpacityAt(double time, bool hidden) } double fadeInStartTime = BaseObject.StartTime - BaseObject.TimePreempt; - double fadeInDuration = BaseObject.TimeFadeIn; + + // Equal to `OsuHitObject.TimeFadeIn` minus any adjustments from the HD mod. + double fadeInDuration = 400 * Math.Min(1, BaseObject.TimePreempt / OsuHitObject.PREEMPT_MIN); if (hidden) { @@ -175,9 +188,12 @@ public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj) { double currDeltaTime = Math.Max(1, DeltaTime); double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); + double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); + double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2); + double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5); + return 1.0 - Math.Pow(speedRatio, 1 - windowRatio); } @@ -189,10 +205,12 @@ private void setDistances(double clockRate) if (BaseObject is Slider currentSlider) { // Bonus for repeat sliders until a better per nested object strain system can be achieved. - TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); + TravelDistance = LazyTravelDistance * Math.Max(1, Math.Pow(currentSlider.RepeatCount, 0.3)); TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME); } + MinimumJumpTime = AdjustedDeltaTime; + // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner if (BaseObject is Spinner || LastObject is Spinner) return; @@ -202,8 +220,8 @@ private void setDistances(double clockRate) Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition; - LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; - MinimumJumpTime = AdjustedDeltaTime; + JumpDistance = (LastObject.StackedPosition - BaseObject.StackedPosition).Length * scalingFactor; + LazyJumpDistance = (BaseObject.StackedPosition - lastCursorPosition).Length * scalingFactor; MinimumJumpDistance = LazyJumpDistance; if (LastObject is Slider lastSlider && lastDifficultyObject != null) @@ -239,15 +257,18 @@ private void setDistances(double clockRate) if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner) { + if (lastDifficultyObject!.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0) + lastCursorPosition = prevSlider.HeadCircle.StackedPosition; + Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject); - Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition; - Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition; + double angle = calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition); + double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition); - float dot = Vector2.Dot(v1, v2); - float det = v1.X * v2.Y - v1.Y * v2.X; + Vector2 v = BaseObject.StackedPosition - lastCursorPosition; + NormalisedVectorAngle = Math.Atan2(Math.Abs(v.Y), Math.Abs(v.X)); - Angle = Math.Abs(Math.Atan2(det, dot)); + Angle = Math.Min(angle, sliderAngle); } } @@ -359,6 +380,30 @@ private void computeSliderCursorPosition() } } + private double calculateSliderAngle(OsuDifficultyHitObject lastDifficultyObject, Vector2 lastLastCursorPosition) + { + Vector2 lastCursorPosition = getEndCursorPosition(lastDifficultyObject); + + if (lastDifficultyObject.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0) + { + OsuHitObject secondLastNestedObject = (OsuHitObject)prevSlider.NestedHitObjects[^2]; + lastLastCursorPosition = secondLastNestedObject.StackedPosition; + } + + return calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition); + } + + private double calculateAngle(Vector2 currentPosition, Vector2 lastPosition, Vector2 lastLastPosition) + { + Vector2 v1 = lastLastPosition - lastPosition; + Vector2 v2 = currentPosition - lastPosition; + + float dot = Vector2.Dot(v1, v2); + float det = v1.X * v2.Y - v1.Y * v2.X; + + return Math.Abs(Math.Atan2(det, dot)); + } + private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject) { return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 5816d27a5e81..a9bad06d3f05 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -5,9 +5,12 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Difficulty.Evaluators; -using osu.Game.Rulesets.Osu.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Skills @@ -15,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances. /// - public class Aim : OsuStrainSkill + public class Aim : TimeSkill { public readonly bool IncludeSliders; @@ -27,19 +30,45 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; - private double skillMultiplier => 26; - private double strainDecayBase => 0.15; + private double skillMultiplierSnap => 355.0; + private double skillMultiplierAgility => 10.0; + private double skillMultiplierFlow => 1100; + private double skillMultiplierTotal => 1.05; + private double combinedSnapNormExponent => 1.2; private readonly List sliderStrains = new List(); - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + protected override double HitProbability(double skill, double difficulty) + { + if (difficulty <= 0) return 1; + if (skill <= 0) return 0; + + double baseDeviation = difficulty / skill; + // at what point does the player lose the ability to aim normally + // increasing this will like high misscount scores more than ringtone maps, and vice versa + const double limit_of_proportion = 0.727; + // how quickly does the player lose the ability to aim normally at the limit of proportion + // increasing this has a similar effect as increasing the limit of proportion, but it changes how significant the effect is across maps + const double breakdown_rate = 30; + double adjustedDeviation = baseDeviation + Math.Exp(breakdown_rate * (baseDeviation - limit_of_proportion)); + + return DifficultyCalculationUtils.Erf(1 / (Math.Sqrt(2) * adjustedDeviation)); + } - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); + private double strainDecay(double ms) => Math.Pow(0.2, ms / 1000); protected override double StrainValueAt(DifficultyHitObject current) { - currentStrain *= strainDecay(current.DeltaTime); - currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier; + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + + double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierSnap; + double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility; + double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow; + + double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty); + + currentStrain *= decay; + currentStrain += totalDifficulty * (1 - decay); if (current.BaseObject is Slider) sliderStrains.Add(currentStrain); @@ -47,6 +76,56 @@ protected override double StrainValueAt(DifficultyHitObject current) return currentStrain; } + private double calculateTotalValue(double snapDifficulty, double agilityDifficulty, double flowDifficulty) + { + // We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams + // Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping + // So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow + double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty); + + double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty); + double pFlow = 1 - pSnap; + + if (Mods.Any(m => m is OsuModTouchDevice)) + { + // we don't adjust agility here since agility represents TD difficulty in a decent enough way + snapDifficulty = Math.Pow(snapDifficulty, 0.89); + combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty); + } + + if (Mods.Any(m => m is OsuModRelax)) + { + combinedSnapDifficulty *= 0.75; + flowDifficulty *= 0.6; + } + + double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow; + + double totalStrain = totalDifficulty * skillMultiplierTotal; + + return totalStrain; + } + + // A function that turns the ratio of snap : flow into the probability of snapping/flowing + // It has the constraints: + // P(snap) + P(flow) = 1 (the object is always either snapped or flowed) + // P(snap) = f(snap/flow), P(flow) = f(flow/snap) (ie snap and flow are symmetric and reversible) + // Therefore: f(x) + f(1/x) = 1 + // 0 <= f(x) <= 1 (cannot have negative or greater than 100% probability of snapping or flowing) + // This logistic function is a solution, which fits nicely with the general idea of interpolation and provides a tuneable constant + private static double calculateSnapFlowProbability(double ratio) + { + const double k = 7.27; + + if (ratio == 0) + return 0; + + if (double.IsNaN(ratio)) + return 1; + + return DifficultyCalculationUtils.Logistic(-k * Math.Log(ratio)); + } + public double GetDifficultSliders() { if (sliderStrains.Count == 0) @@ -60,6 +139,18 @@ public double GetDifficultSliders() return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0)))); } - public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue()); + public double CountTopWeightedSliders(double difficultyValue) + { + if (sliderStrains.Count == 0) + return 0; + + double consistentTopStrain = difficultyValue * (1 - 0.9); // What would the top strain be if all strain values were identical + + if (consistentTopStrain == 0) + return 0; + + // Use a weighted sum of all strains. Constants are arbitrary and give nice values + return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 6c839eac3fef..44c39cbb9cd0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -7,7 +7,6 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; -using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -16,15 +15,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Flashlight : StrainSkill { - private readonly bool hasHiddenMod; - public Flashlight(Mod[] mods) : base(mods) { - hasHiddenMod = mods.Any(m => m is OsuModHidden); } - private double skillMultiplier => 0.05512; + private double skillMultiplier => 0.056; private double strainDecayBase => 0.15; private double currentStrain; @@ -36,7 +32,7 @@ public Flashlight(Mod[] mods) protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier; + currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; return currentStrain; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs deleted file mode 100644 index 6823512cef12..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Mods; -using System.Linq; -using osu.Framework.Utils; - -namespace osu.Game.Rulesets.Osu.Difficulty.Skills -{ - public abstract class OsuStrainSkill : StrainSkill - { - /// - /// The number of sections with the highest strains, which the peak strain reductions will apply to. - /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. - /// - protected virtual int ReducedSectionCount => 10; - - /// - /// The baseline multiplier applied to the section with the biggest strain. - /// - protected virtual double ReducedStrainBaseline => 0.75; - - protected OsuStrainSkill(Mod[] mods) - : base(mods) - { - } - - public override double DifficultyValue() - { - double difficulty = 0; - double weight = 1; - - // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These sections will not contribute to the difficulty. - var peaks = GetCurrentStrainPeaks().Where(p => p > 0); - - List strains = peaks.OrderDescending().ToList(); - - // We are reducing the highest strains first to account for extreme difficulty spikes - for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++) - { - double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / ReducedSectionCount, 0, 1))); - strains[i] *= Interpolation.Lerp(ReducedStrainBaseline, 1.0, scale); - } - - // Difficulty is the weighted sum of the highest strains from every section. - // We're sorting from highest to lowest strain. - foreach (double strain in strains.OrderDescending()) - { - difficulty += strain * weight; - weight *= DecayWeight; - } - - return difficulty; - } - - public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0; - } -} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs new file mode 100644 index 000000000000..414cf1f1f9b8 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.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 System.Linq; +using osu.Framework.Utils; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Difficulty.Skills +{ + public class Reading : HarmonicSkill + { + private readonly List objectList = new List(); + + private readonly bool hasHiddenMod; + + public Reading(Mod[] mods) + : base(mods) + { + hasHiddenMod = mods.OfType().Any(m => !m.OnlyFadeApproachCircles.Value); + } + + private double currentDifficulty; + + private double skillMultiplier => 2.5; + private double strainDecayBase => 0.8; + + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + protected override double ObjectDifficultyOf(DifficultyHitObject current) + { + objectList.Add(current); + + double decay = strainDecay(current.DeltaTime); + + currentDifficulty *= decay; + + currentDifficulty += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * (1 - decay) * skillMultiplier; + + return currentDifficulty; + } + + protected override void ApplyDifficultyTransformation(double[] difficulties) + { + const double reduced_difficulty_base_line = 0.0; // Assume the first seconds are completely memorised + + int reducedNoteCount = calculateReducedNoteCount(); + + for (int i = 0; i < Math.Min(difficulties.Length, reducedNoteCount); i++) + { + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((double)i / reducedNoteCount, 0, 1))); + difficulties[i] *= Interpolation.Lerp(reduced_difficulty_base_line, 1.0, scale); + } + } + + private int calculateReducedNoteCount() + { + const double reduced_difficulty_duration = 60 * 1000; + + if (objectList.Count == 0) + return 0; + + double reducedDuration = objectList.First().StartTime + reduced_difficulty_duration; + + int reducedNoteCount = 0; + + foreach (var hitObject in objectList) + { + if (hitObject.StartTime > reducedDuration) + break; + + reducedNoteCount++; + } + + return reducedNoteCount; + } + + public override double CountTopWeightedObjectDifficulties(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + if (NoteWeightSum == 0) + return 0.0; + + double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top difficulty be if all object difficulties were identical + + if (consistentTopNote == 0) + return 0; + + return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopNote, 1.15, 5, 1.1)); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 8fe3df43470e..cdd01187030d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -5,28 +5,30 @@ using System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Difficulty.Evaluators; -using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; using System.Linq; -using osu.Game.Rulesets.Osu.Difficulty.Utils; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { /// /// Represents the skill required to press keys with regards to keeping up with the speed at which objects need to be hit. /// - public class Speed : OsuStrainSkill + public class Speed : HarmonicSkill { - private double skillMultiplier => 1.47; - private double strainDecayBase => 0.3; - - private double currentStrain; - private double currentRhythm; + private double skillMultiplier => 1.16; private readonly List sliderStrains = new List(); - protected override int ReducedSectionCount => 5; + private double currentDifficulty; + + private double strainDecayBase => 0.3; + + protected override double HarmonicScale => 20; + protected override double DecayExponent => 0.9; public Speed(Mod[] mods) : base(mods) @@ -35,35 +37,51 @@ public Speed(Mod[] mods) private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (currentStrain * currentRhythm) * strainDecay(time - current.Previous(0).StartTime); - - protected override double StrainValueAt(DifficultyHitObject current) + protected override double ObjectDifficultyOf(DifficultyHitObject current) { - currentStrain *= strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + + currentDifficulty *= decay; + currentDifficulty += SpeedEvaluator.EvaluateDifficultyOf(current) * (1 - decay) * skillMultiplier; - currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); + double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); - double totalStrain = currentStrain * currentRhythm; + double totalDifficulty = currentDifficulty * currentRhythm; if (current.BaseObject is Slider) - sliderStrains.Add(totalStrain); + sliderStrains.Add(totalDifficulty); - return totalStrain; + return totalDifficulty; } public double RelevantNoteCount() { - if (ObjectStrains.Count == 0) + if (ObjectDifficulties.Count == 0) return 0; - double maxStrain = ObjectStrains.Max(); + double maxStrain = ObjectDifficulties.Max(); + if (maxStrain == 0) return 0; - return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); + return ObjectDifficulties.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } - public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue()); + public double CountTopWeightedSliders(double difficultyValue) + { + if (sliderStrains.Count == 0) + return 0; + + if (NoteWeightSum == 0) + return 0.0; + + double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top note be if all note values were identical + + if (consistentTopNote == 0) + return 0; + + // Use a weighted sum of all notes. Constants are arbitrary and give nice values + return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopNote, 0.88, 10, 1.1)); + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs deleted file mode 100644 index 8a78192ee4cf..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using osu.Game.Rulesets.Difficulty.Utils; - -namespace osu.Game.Rulesets.Osu.Difficulty.Utils -{ - public static class OsuStrainUtils - { - public static double CountTopWeightedSliders(IReadOnlyCollection sliderStrains, double difficultyValue) - { - if (sliderStrains.Count == 0) - return 0; - - double consistentTopStrain = difficultyValue / 10; // What would the top strain be if all strain values were identical - - if (consistentTopStrain == 0) - return 0; - - // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs index f54dc2c85b88..07856f11e0af 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs @@ -25,7 +25,7 @@ private void load(OsuGridToolboxGroup gridToolboxGroup) { this.gridToolboxGroup = gridToolboxGroup; originalOrigin = gridToolboxGroup.StartPosition.Value; - originalSpacing = gridToolboxGroup.Spacing.Value; + originalSpacing = gridToolboxGroup.GridLineSpacing.Value; originalRotation = gridToolboxGroup.GridLinesRotation.Value; } @@ -67,7 +67,7 @@ protected override bool OnMouseDown(MouseDownEvent e) { // Reset the grid to the default values. gridToolboxGroup.StartPosition.Value = gridToolboxGroup.StartPosition.Default; - gridToolboxGroup.Spacing.Value = gridToolboxGroup.Spacing.Default; + gridToolboxGroup.GridLineSpacing.Value = gridToolboxGroup.GridLineSpacing.Default; if (!gridToolboxGroup.GridLinesRotation.Disabled) gridToolboxGroup.GridLinesRotation.Value = gridToolboxGroup.GridLinesRotation.Default; EndPlacement(true); @@ -112,7 +112,7 @@ public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, do // Default to the original spacing and rotation if the distance is too small. if (Vector2.Distance(gridToolboxGroup.StartPosition.Value, pos) < 2) { - gridToolboxGroup.Spacing.Value = originalSpacing; + gridToolboxGroup.GridLineSpacing.Value = originalSpacing; if (!gridToolboxGroup.GridLinesRotation.Disabled) gridToolboxGroup.GridLinesRotation.Value = originalRotation; } @@ -134,7 +134,7 @@ protected override void PopOut() private void resetGridState() { gridToolboxGroup.StartPosition.Value = originalOrigin; - gridToolboxGroup.Spacing.Value = originalSpacing; + gridToolboxGroup.GridLineSpacing.Value = originalSpacing; if (!gridToolboxGroup.GridLinesRotation.Disabled) gridToolboxGroup.GridLinesRotation.Value = originalRotation; } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index d934eb5a9e9f..b5b4c8c87d5c 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -58,7 +58,7 @@ public partial class SliderPlacementBlueprint : HitObjectPlacementBlueprint private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 }; - protected override bool IsValidForPlacement => HitObject.Path.HasValidLengthForPlacement; + protected override bool IsValidForPlacement => base.IsValidForPlacement && (PlacementActive == PlacementState.Waiting || HitObject.Path.HasValidLengthForPlacement); public SliderPlacementBlueprint() : base(new Slider()) diff --git a/osu.Game.Rulesets.Osu/Edit/FreehandSliderToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/FreehandSliderToolboxGroup.cs index f17118ba349d..6f8c58e1e45e 100644 --- a/osu.Game.Rulesets.Osu/Edit/FreehandSliderToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/FreehandSliderToolboxGroup.cs @@ -5,8 +5,10 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; +using osuTK; namespace osu.Game.Rulesets.Osu.Edit { @@ -42,25 +44,31 @@ public FreehandSliderToolboxGroup() private readonly BindableInt displayTolerance = new BindableInt(90) { MinValue = 5, - MaxValue = 100 + MaxValue = 100, + Precision = 1, }; private readonly BindableInt displayCornerThreshold = new BindableInt(40) { MinValue = 5, - MaxValue = 100 + MaxValue = 100, + Precision = 1, }; private readonly BindableInt displayCircleThreshold = new BindableInt(30) { MinValue = 0, - MaxValue = 100 + MaxValue = 100, + Precision = 1, }; private ExpandableSlider toleranceSlider = null!; private ExpandableSlider cornerThresholdSlider = null!; private ExpandableSlider circleThresholdSlider = null!; + [Resolved] + private IExpandingContainer? expandingContainer { get; set; } + [BackgroundDependencyLoader] private void load() { @@ -68,15 +76,18 @@ private void load() { toleranceSlider = new ExpandableSlider { - Current = displayTolerance + Current = displayTolerance, + ExpandedLabelText = "Control point spacing", }, cornerThresholdSlider = new ExpandableSlider { - Current = displayCornerThreshold + Current = displayCornerThreshold, + ExpandedLabelText = "Corner bias", }, circleThresholdSlider = new ExpandableSlider { - Current = displayCircleThreshold + Current = displayCircleThreshold, + ExpandedLabelText = "Perfect curve bias" } }; } @@ -88,24 +99,18 @@ protected override void LoadComplete() displayTolerance.BindValueChanged(tolerance => { toleranceSlider.ContractedLabelText = $"C. P. S.: {tolerance.NewValue:N0}"; - toleranceSlider.ExpandedLabelText = $"Control Point Spacing: {tolerance.NewValue:N0}"; - Tolerance.Value = displayToInternalTolerance(tolerance.NewValue); }, true); displayCornerThreshold.BindValueChanged(threshold => { - cornerThresholdSlider.ContractedLabelText = $"C. T.: {threshold.NewValue:N0}"; - cornerThresholdSlider.ExpandedLabelText = $"Corner Threshold: {threshold.NewValue:N0}"; - + cornerThresholdSlider.ContractedLabelText = $"C. B.: {threshold.NewValue:N0}"; CornerThreshold.Value = displayToInternalCornerThreshold(threshold.NewValue); }, true); displayCircleThreshold.BindValueChanged(threshold => { - circleThresholdSlider.ContractedLabelText = $"P. C. T.: {threshold.NewValue:N0}"; - circleThresholdSlider.ExpandedLabelText = $"Perfect Curve Threshold: {threshold.NewValue:N0}"; - + circleThresholdSlider.ContractedLabelText = $"P. C. B.: {threshold.NewValue:N0}"; CircleThreshold.Value = displayToInternalCircleThreshold(threshold.NewValue); }, true); @@ -119,6 +124,11 @@ protected override void LoadComplete() displayCircleThreshold.Value = internalToDisplayCircleThreshold(threshold.NewValue) ); + expandingContainer?.Expanded.BindValueChanged(v => + { + Spacing = v.NewValue ? new Vector2(5) : new Vector2(15); + }, true); + float displayToInternalTolerance(float v) => v / 50f; int internalToDisplayTolerance(float v) => (int)Math.Round(v * 50f); diff --git a/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs index 991d42c7b4c3..5cc25630aaca 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs @@ -38,7 +38,7 @@ public partial class OsuGridToolboxGroup : EditorToolboxGroup, IKeyBindingHandle { MinValue = 0f, MaxValue = OsuPlayfield.BASE_SIZE.X, - Precision = 0.01f, + Precision = 0.1f, }; /// @@ -48,17 +48,17 @@ public partial class OsuGridToolboxGroup : EditorToolboxGroup, IKeyBindingHandle { MinValue = 0f, MaxValue = OsuPlayfield.BASE_SIZE.Y, - Precision = 0.01f, + Precision = 0.1f, }; /// /// The spacing between grid lines. /// - public BindableFloat Spacing { get; } = new BindableFloat(4f) + public BindableFloat GridLineSpacing { get; } = new BindableFloat(4f) { MinValue = 4f, MaxValue = 256f, - Precision = 0.01f, + Precision = 0.1f, }; /// @@ -68,7 +68,7 @@ public partial class OsuGridToolboxGroup : EditorToolboxGroup, IKeyBindingHandle { MinValue = -180f, MaxValue = 180f, - Precision = 0.01f, + Precision = 0.1f, }; /// @@ -115,7 +115,7 @@ public void SetGridFromPoints(Vector2 point1, Vector2 point2) float dist = Vector2.Distance(point1, point2); while (dist >= max_automatic_spacing) dist /= 2; - Spacing.Value = dist; + GridLineSpacing.Value = dist; } [BackgroundDependencyLoader] @@ -127,21 +127,25 @@ private void load() { Current = StartPositionX, KeyboardStep = 1, + ExpandedLabelText = "X offset", }, startPositionYSlider = new ExpandableSlider { Current = StartPositionY, KeyboardStep = 1, + ExpandedLabelText = "Y offset", }, spacingSlider = new ExpandableSlider { - Current = Spacing, + Current = GridLineSpacing, KeyboardStep = 1, + ExpandedLabelText = "Spacing", }, gridLinesRotationSlider = new ExpandableSlider { Current = GridLinesRotation, KeyboardStep = 1, + ExpandedLabelText = "Rotation", }, new FillFlowContainer { @@ -170,7 +174,7 @@ private void load() }, }; - Spacing.Value = editorBeatmap.GridSize; + GridLineSpacing.Value = editorBeatmap.GridSize; } protected override void LoadComplete() @@ -182,14 +186,12 @@ protected override void LoadComplete() StartPositionX.BindValueChanged(x => { startPositionXSlider.ContractedLabelText = $"X: {x.NewValue:#,0.##}"; - startPositionXSlider.ExpandedLabelText = $"X Offset: {x.NewValue:#,0.##}"; StartPosition.Value = new Vector2(x.NewValue, StartPosition.Value.Y); }, true); StartPositionY.BindValueChanged(y => { startPositionYSlider.ContractedLabelText = $"Y: {y.NewValue:#,0.##}"; - startPositionYSlider.ExpandedLabelText = $"Y Offset: {y.NewValue:#,0.##}"; StartPosition.Value = new Vector2(StartPosition.Value.X, y.NewValue); }, true); @@ -199,10 +201,9 @@ protected override void LoadComplete() StartPositionY.Value = pos.NewValue.Y; }); - Spacing.BindValueChanged(spacing => + GridLineSpacing.BindValueChanged(spacing => { spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:#,0.##}"; - spacingSlider.ExpandedLabelText = $"Spacing: {spacing.NewValue:#,0.##}"; SpacingVector.Value = new Vector2(spacing.NewValue); editorBeatmap.GridSize = (int)spacing.NewValue; }, true); @@ -210,7 +211,6 @@ protected override void LoadComplete() GridLinesRotation.BindValueChanged(rotation => { gridLinesRotationSlider.ContractedLabelText = $"R: {rotation.NewValue:#,0.##}"; - gridLinesRotationSlider.ExpandedLabelText = $"Rotation: {rotation.NewValue:#,0.##}"; }, true); GridType.BindValueChanged(v => @@ -239,6 +239,8 @@ protected override void LoadComplete() { gridTypeButtons.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint); gridTypeButtons.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None; + + Spacing = v.NewValue ? new Vector2(5) : new Vector2(15); }, true); } @@ -252,7 +254,7 @@ public bool OnPressed(KeyBindingPressEvent e) switch (e.Action) { case GlobalAction.EditorCycleGridSpacing: - Spacing.Value = Spacing.Value * 2 >= max_automatic_spacing ? Spacing.Value / 8 : Spacing.Value * 2; + GridLineSpacing.Value = GridLineSpacing.Value * 2 >= max_automatic_spacing ? GridLineSpacing.Value / 8 : GridLineSpacing.Value * 2; return true; case GlobalAction.EditorCycleGridType: diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index e4f8ee5b6d09..6ff762b82f06 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using JetBrains.Annotations; @@ -142,7 +143,7 @@ private void updatePositionSnapGrid(ValueChangedEvent obj) case PositionSnapGridType.Triangle: var triangularPositionSnapGrid = new TriangularPositionSnapGrid(); - triangularPositionSnapGrid.Spacing.BindTo(OsuGridToolboxGroup.Spacing); + triangularPositionSnapGrid.Spacing.BindTo(OsuGridToolboxGroup.GridLineSpacing); triangularPositionSnapGrid.GridLineRotation.BindTo(OsuGridToolboxGroup.GridLinesRotation); positionSnapGrid = triangularPositionSnapGrid; @@ -151,7 +152,7 @@ private void updatePositionSnapGrid(ValueChangedEvent obj) case PositionSnapGridType.Circle: var circularPositionSnapGrid = new CircularPositionSnapGrid(); - circularPositionSnapGrid.Spacing.BindTo(OsuGridToolboxGroup.Spacing); + circularPositionSnapGrid.Spacing.BindTo(OsuGridToolboxGroup.GridLineSpacing); positionSnapGrid = circularPositionSnapGrid; break; @@ -171,7 +172,8 @@ protected override ComposeBlueprintContainer CreateBlueprintContainer() => new OsuBlueprintContainer(this); public override string ConvertSelectionToString() - => string.Join(',', selectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => (h.IndexInCurrentCombo + 1).ToString())); + => string.Join(',', selectedHitObjects.Cast().OrderBy(h => h.StartTime) + .Select(h => (h.IndexInCurrentCombo + 1).ToString(CultureInfo.InvariantCulture))); // 1,2,3,4 ... private static readonly Regex selection_regex = new Regex(@"^\d+(,\d+)*$", RegexOptions.Compiled); diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index d5f313776989..17f32ea67d5d 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; @@ -37,13 +38,16 @@ public partial class OsuSelectionScaleHandler : SelectionScaleHandler [Resolved] private IEditorChangeHandler? changeHandler { get; set; } + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + [Resolved(CanBeNull = true)] private IDistanceSnapProvider? snapProvider { get; set; } private BindableList selectedItems { get; } = new BindableList(); [BackgroundDependencyLoader] - private void load(EditorBeatmap editorBeatmap) + private void load() { selectedItems.BindTo(editorBeatmap.SelectedHitObjects); } @@ -53,15 +57,22 @@ protected override void LoadComplete() base.LoadComplete(); selectedItems.CollectionChanged += (_, __) => updateState(); + editorBeatmap.HitObjectUpdated += hitObjectUpdated; updateState(); } + private void hitObjectUpdated(HitObject hitObject) + { + if (selectedMovableObjects.Contains(hitObject)) + updateState(); + } + private void updateState() { var quad = GeometryUtils.GetSurroundingQuad(selectedMovableObjects); - CanScaleX.Value = quad.Width > 0; - CanScaleY.Value = quad.Height > 0; + CanScaleX.Value = Precision.DefinitelyBigger(quad.Width, 0); + CanScaleY.Value = Precision.DefinitelyBigger(quad.Height, 0); CanScaleDiagonally.Value = CanScaleX.Value && CanScaleY.Value; CanScaleFromPlayfieldOrigin.Value = selectedMovableObjects.Any(); IsScalingSlider.Value = selectedMovableObjects.Count() == 1 && selectedMovableObjects.First() is Slider; @@ -339,5 +350,13 @@ public OriginalHitObjectState(OsuHitObject hitObject) PathControlPointTypes = (hitObject as IHasPath)?.Path.ControlPoints.Select(p => p.Type).ToArray(); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorBeatmap.IsNotNull()) + editorBeatmap.HitObjectUpdated -= hitObjectUpdated; + } } } diff --git a/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs index 046f57c0a5eb..fe5e0581ecd2 100644 --- a/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs @@ -25,10 +25,10 @@ namespace osu.Game.Rulesets.Osu.Edit { public partial class PolygonGenerationPopover : OsuPopover { - private SliderWithTextBoxInput distanceSnapInput = null!; - private SliderWithTextBoxInput offsetAngleInput = null!; - private SliderWithTextBoxInput repeatCountInput = null!; - private SliderWithTextBoxInput pointInput = null!; + private FormSliderBar distanceSnapInput { get; set; } = null!; + private FormSliderBar offsetAngleInput { get; set; } = null!; + private FormSliderBar repeatCountInput { get; set; } = null!; + private FormSliderBar pointInput { get; set; } = null!; private RoundedButton commitButton = null!; private readonly List insertedCircles = new List(); @@ -64,11 +64,12 @@ private void load() { Width = 220, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(5), Children = new Drawable[] { - distanceSnapInput = new SliderWithTextBoxInput("Distance snap:") + distanceSnapInput = new FormSliderBar { + Caption = "Distance snap", Current = new BindableNumber(1) { MinValue = 0.1, @@ -76,37 +77,40 @@ private void load() Precision = 0.1, Value = ((OsuHitObjectComposer)composer).DistanceSnapProvider.DistanceSpacingMultiplier.Value, }, - Instantaneous = true + TabbableContentContainer = this }, - offsetAngleInput = new SliderWithTextBoxInput("Offset angle:") + offsetAngleInput = new FormSliderBar { + Caption = "Offset angle", Current = new BindableNumber { MinValue = 0, MaxValue = 180, Precision = 1 }, - Instantaneous = true + TabbableContentContainer = this }, - repeatCountInput = new SliderWithTextBoxInput("Repeats:") + repeatCountInput = new FormSliderBar { + Caption = "Repeats", Current = new BindableNumber(1) { MinValue = 1, MaxValue = 10, Precision = 1 }, - Instantaneous = true + TabbableContentContainer = this }, - pointInput = new SliderWithTextBoxInput("Vertices:") + pointInput = new FormSliderBar { + Caption = "Vertices", Current = new BindableNumber(3) { MinValue = 3, MaxValue = 32, Precision = 1, }, - Instantaneous = true + TabbableContentContainer = this }, commitButton = new RoundedButton { diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs index f3739ab445cc..caac51632b13 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs @@ -37,7 +37,7 @@ public partial class PreciseMovementPopover : OsuPopover private BindableNumber xBindable = null!; private BindableNumber yBindable = null!; - private SliderWithTextBoxInput xInput = null!; + private FormSliderBar xInput { get; set; } = null!; private OsuCheckbox relativeCheckbox = null!; public PreciseMovementPopover() @@ -52,31 +52,31 @@ private void load() { Width = 220, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(5), Children = new Drawable[] { - xInput = new SliderWithTextBoxInput("X:") + xInput = new FormSliderBar { + Caption = "X", Current = xBindable = new BindableNumber { Precision = 1, }, - Instantaneous = true, - TabbableContentContainer = this, + TabbableContentContainer = this }, - new SliderWithTextBoxInput("Y:") + new FormSliderBar { + Caption = "Y", Current = yBindable = new BindableNumber { Precision = 1, }, - Instantaneous = true, - TabbableContentContainer = this, + TabbableContentContainer = this }, relativeCheckbox = new OsuCheckbox(false) { RelativeSizeAxes = Axes.X, - LabelText = "Relative movement", + LabelText = "Relative movement" } } }; diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs index e2cde1a3258e..959963ed338c 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -28,7 +28,7 @@ public partial class PreciseRotationPopover : OsuPopover private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, EditorOrigin.GridCentre)); - private SliderWithTextBoxInput angleInput = null!; + private FormSliderBar angleInput { get; set; } = null!; private EditorRadioButtonCollection rotationOrigin = null!; private RadioButton gridCentreButton = null!; @@ -54,11 +54,12 @@ private void load(OsuConfigManager config) { Width = 220, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(5), Children = new Drawable[] { - angleInput = new SliderWithTextBoxInput("Angle (degrees):") + angleInput = new FormSliderBar { + Caption = "Angle (degrees)", Current = new BindableNumber { MinValue = -360, @@ -66,7 +67,7 @@ private void load(OsuConfigManager config) Precision = 1 }, KeyboardStep = 1f, - Instantaneous = true + TabbableContentContainer = this }, rotationOrigin = new EditorRadioButtonCollection { diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs index ca4a99b9cd78..88bf92ecb532 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -32,7 +32,7 @@ public partial class PreciseScalePopover : OsuPopover private readonly Bindable scaleInfo = new Bindable(new PreciseScaleInfo(1, EditorOrigin.GridCentre, true, true)); - private SliderWithTextBoxInput scaleInput = null!; + private FormSliderBar scaleInput { get; set; } = null!; private BindableNumber scaleInputBindable = null!; private EditorRadioButtonCollection scaleOrigin = null!; @@ -66,11 +66,12 @@ private void load(EditorBeatmap editorBeatmap, OsuConfigManager config) { Width = 220, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(5), Children = new Drawable[] { - scaleInput = new SliderWithTextBoxInput("Scale:") + scaleInput = new FormSliderBar { + Caption = "Scale", Current = scaleInputBindable = new BindableNumber { MinValue = 0.05f, @@ -80,7 +81,7 @@ private void load(EditorBeatmap editorBeatmap, OsuConfigManager config) Default = 1, }, KeyboardStep = 0.01f, - Instantaneous = true + TabbableContentContainer = this }, scaleOrigin = new EditorRadioButtonCollection { diff --git a/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs index b56fdbdf74e7..34eb2be07743 100644 --- a/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs +++ b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs @@ -67,6 +67,9 @@ public void Update(Playfield playfield) { if (LastAcceptedAction != null && nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) LastAcceptedAction = null; + + if (LastAcceptedAction != null && gameplayClock.IsRewinding) + LastAcceptedAction = null; } protected abstract bool CheckValidNewAction(OsuAction action); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs index d01b56195473..f1a56fb1a24e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs @@ -16,6 +16,7 @@ public class OsuModAlternate : InputBlockingMod public override LocalisableString Description => @"Don't use the same key twice in a row!"; public override IconUsage? Icon => OsuIcon.ModAlternate; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModSingleTap) }).ToArray(); + public override bool Ranked => true; protected override bool CheckValidNewAction(OsuAction action) => LastAcceptedAction != action; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs index 445fb8b37ad3..8947565992be 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs @@ -39,7 +39,7 @@ public class OsuModBloom : Mod, IApplicableToScoreProcessor, IUpdatableByPlayfie [SettingSource( "Max size at combo", "The combo count at which the cursor reaches its maximum size", - SettingControlType = typeof(SettingsSlider>) + SettingControlType = typeof(SettingsSlider) )] public BindableInt MaxSizeComboCount { get; } = new BindableInt(50) { @@ -85,4 +85,12 @@ public void Update(Playfield playfield) cursor.ModScaleAdjust.Value = (float)Interpolation.Lerp(cursor.ModScaleAdjust.Value, currentSize, Math.Clamp(cursor.Time.Elapsed / TRANSITION_DURATION, 0, 1)); } } + + public partial class MaxSizeComboSlider : RoundedSliderBar + { + public MaxSizeComboSlider() + { + KeyboardStep = 1; + } + } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index 0d6b02a7d14b..65ab6001c983 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Game.Beatmaps; @@ -71,6 +73,8 @@ string format(string acronym, DifficultyBindable bindable) } } + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModTargetPractice)).ToArray(); + protected override void ApplySettings(BeatmapDifficulty difficulty) { base.ApplySettings(difficulty); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs index e75ed24a7dc3..b89280212746 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs @@ -27,8 +27,10 @@ public class OsuModFreezeFrame : Mod, IApplicableToDrawableHitObject, IApplicabl public override LocalisableString Description => "Burn the notes into your memory."; - //Alters the transforms of the approach circles, breaking the effects of these mods. - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModApproachDifferent), typeof(OsuModTransform), typeof(OsuModDepth) }).ToArray(); + /// + /// Incompatible with all mods that directly modify or indirectly depend on , or alter the behaviour of approach circles. + /// + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModApproachDifferent), typeof(OsuModTransform), typeof(OsuModDepth), typeof(OsuModHidden) }).ToArray(); public override ModType Type => ModType.Fun; @@ -57,16 +59,25 @@ public void ApplyToBeatmap(IBeatmap beatmap) void applyFadeInAdjustment(OsuHitObject osuObject) { - osuObject.TimePreempt += osuObject.StartTime - lastNewComboTime; + if (osuObject is not Spinner) + osuObject.TimePreempt += osuObject.StartTime - lastNewComboTime; + + int repeatCount = 0; foreach (var nested in osuObject.NestedHitObjects.OfType()) { switch (nested) { - //Freezing the SliderTicks doesnt play well with snaking sliders + // Freezing the SliderTicks doesnt play well with snaking sliders case SliderTick: - //SliderRepeat wont layer correctly if preempt is changed. + break; + case SliderRepeat: + if (repeatCount > 2) + break; + + applyFadeInAdjustment(nested); + repeatCount++; break; default: diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 6dc0d5d5229f..c9b5fb29f776 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -26,7 +26,7 @@ public class OsuModHidden : ModHidden, IHidesApproachCircles public override LocalisableString Description => @"Play with no approach circles and fading circles/sliders."; public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; - public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn), typeof(OsuModDepth) }; + public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn), typeof(OsuModDepth), typeof(OsuModFreezeFrame) }; public const double FADE_IN_DURATION_MULTIPLIER = 0.4; public const double FADE_OUT_DURATION_MULTIPLIER = 0.3; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs index 6d16598f8936..2fc646846dd8 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs @@ -16,6 +16,7 @@ public class OsuModSingleTap : InputBlockingMod public override IconUsage? Icon => OsuIcon.ModSingleTap; public override LocalisableString Description => @"You must only use one key!"; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAlternate) }).ToArray(); + public override bool Ranked => true; protected override bool CheckValidNewAction(OsuAction action) => LastAcceptedAction == null || LastAcceptedAction == action; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs index e82ec2fb104f..22dcd5079a08 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs @@ -48,7 +48,8 @@ public class OsuModTargetPractice : ModWithVisibilityAdjustment, IApplicableToDr typeof(OsuModSpunOut), typeof(OsuModStrictTracking), typeof(OsuModSuddenDeath), - typeof(OsuModDepth) + typeof(OsuModDepth), + typeof(OsuModDifficultyAdjust), }).ToArray(); [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index b2a3da285c48..6e9afb493da1 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -21,7 +21,7 @@ public class OsuModTraceable : ModWithVisibilityAdjustment, IRequiresApproachCir public override string Name => "Traceable"; public override string Acronym => "TC"; public override IconUsage? Icon => OsuIcon.ModTraceable; - public override ModType Type => ModType.Fun; + public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => "Put your faith in the approach circles..."; public override double ScoreMultiplier => 1; public override bool Ranked => true; diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 01309c68f62b..0a5365e652eb 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -171,7 +171,7 @@ protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, I { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, PREEMPT_RANGE); + TimePreempt = IBeatmapDifficultyInfo.DifficultyRangeInt(difficulty.ApproachRate, PREEMPT_RANGE); // Preempt time can go below 450ms. Normally, this is achieved via the DT mod which uniformly speeds up all animations game wide regardless of AR. // This uniform speedup is hard to match 1:1, however we can at least make AR>10 (via mods) feel good by extending the upper linear function above. diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 49d945e0aa39..f0c4e7441063 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -175,7 +175,7 @@ public override IEnumerable GetModsFor(ModType type) new OsuModHardRock(), new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()), new MultiMod(new OsuModDoubleTime(), new OsuModNightcore()), - new OsuModHidden(), + new MultiMod(new OsuModHidden(), new OsuModTraceable()), new MultiMod(new OsuModFlashlight(), new OsuModBlinds()), new OsuModStrictTracking(), new OsuModAccuracyChallenge(), @@ -209,7 +209,6 @@ public override IEnumerable GetModsFor(ModType type) new OsuModSpinIn(), new MultiMod(new OsuModGrow(), new OsuModDeflate()), new MultiMod(new ModWindUp(), new ModWindDown()), - new OsuModTraceable(), new OsuModBarrelRoll(), new OsuModApproachDifferent(), new OsuModMuted(), @@ -278,19 +277,24 @@ public override IEnumerable GetModsFor(ModType type) public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new OsuRulesetConfigManager(settings, RulesetInfo); - protected override IEnumerable GetValidHitResults() + public override IEnumerable GetValidHitResults() { return new[] { HitResult.Great, HitResult.Ok, HitResult.Meh, + HitResult.Miss, HitResult.LargeTickHit, + HitResult.LargeTickMiss, HitResult.SmallTickHit, + HitResult.SmallTickMiss, HitResult.SliderTailHit, HitResult.SmallBonus, HitResult.LargeBonus, + HitResult.IgnoreHit, + HitResult.IgnoreMiss, }; } @@ -412,7 +416,8 @@ public override IEnumerable GetBeatmapAttributesForDisp Description = "Affects how early objects appear on screen relative to their hit time.", AdditionalMetrics = [ - new RulesetBeatmapAttribute.AdditionalMetric("Approach time", LocalisableString.Interpolate($@"{IBeatmapDifficultyInfo.DifficultyRange(effectiveDifficulty.ApproachRate, OsuHitObject.PREEMPT_RANGE):#,0.##} ms")) + new RulesetBeatmapAttribute.AdditionalMetric("Approach time", + LocalisableString.Interpolate($@"{IBeatmapDifficultyInfo.DifficultyRangeInt(effectiveDifficulty.ApproachRate, OsuHitObject.PREEMPT_RANGE):#,0.##} ms")) ] }; diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index d43e6092c22c..f08e8133230c 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Osu.Replays { public class OsuAutoGenerator : OsuAutoGeneratorBase { + public const double MIN_FRAME_SEPARATION_FOR_ALTERNATING = 266; + public new OsuBeatmap Beatmap => (OsuBeatmap)base.Beatmap; #region Parameters @@ -245,7 +247,7 @@ private void moveToHitObject(OsuHitObject h, Vector2 targetPos, Easing easing) double timeDifference = ApplyModsToTimeDelta(lastFrame.Time, h.StartTime); OsuReplayFrame? lastLastFrame = Frames.Count >= 2 ? (OsuReplayFrame)Frames[^2] : null; - if (timeDifference > 0) + if (timeDifference >= 0) { // If the last frame is a key-up frame and there has been no wait period, adjust the last frame's position such that it begins eased movement instantaneously. if (lastLastFrame != null && lastFrame is OsuKeyUpReplayFrame && !hasWaited) @@ -266,7 +268,7 @@ private void moveToHitObject(OsuHitObject h, Vector2 targetPos, Easing easing) } // Start alternating once the time separation is too small (faster than ~225BPM). - if (timeDifference > 0 && timeDifference < 266) + if (timeDifference >= 0 && timeDifference < MIN_FRAME_SEPARATION_FOR_ALTERNATING) buttonIndex++; else buttonIndex = 0; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index 7118b6f95e1b..219e754dccd7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -103,6 +103,9 @@ public OsuLegacySkinTransformer(ISkin skin) leaderboard.Origin = Anchor.BottomLeft; leaderboard.Position = pos; } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { Children = new Drawable[] diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs index f4fe42b8de5f..2962bce635cd 100644 --- a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs +++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs @@ -77,9 +77,14 @@ protected override void LoadComplete() base.LoadComplete(); RelativeSizeAxes = Axes.Both; + } - LifetimeStart = smokeStartTime = Time.Current; - + public void StartDrawing(double time) + { + LifetimeStart = smokeStartTime = time; + LifetimeEnd = smokeEndTime = double.MaxValue; + SmokePoints.Clear(); + lastPosition = null; totalDistance = pointInterval; } diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 1c2d69fa00e4..8ddb599c2e42 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -49,6 +49,18 @@ public partial class CursorTrail : Drawable, IRequireHighFrequencyMousePosition /// protected bool AllowPartRotation { get; set; } + private Vector2 cursorScale = Vector2.One; + + public Vector2 CursorScale + { + get => cursorScale; + set + { + cursorScale = value; + Invalidate(Invalidation.DrawNode); + } + } + /// /// The trail part texture origin. /// @@ -186,7 +198,7 @@ protected void AddTrail(Vector2 position) float distance = diff.Length; Vector2 direction = diff / distance; - float interval = Texture.DisplayWidth / 2.5f * IntervalMultiplier; + float interval = Texture.DisplayWidth * CursorScale.X / 2.5f * IntervalMultiplier; float stopAt = distance - (AvoidDrawingNearCursor ? interval : 0); for (float d = interval; d < stopAt; d += interval) @@ -233,6 +245,7 @@ private class TrailDrawNode : DrawNode private float time; private float fadeExponent; private float angle; + private Vector2 cursorScale; private readonly TrailPart[] parts = new TrailPart[max_sprites]; private Vector2 originPosition; @@ -253,6 +266,7 @@ public override void ApplyState() time = Source.time; fadeExponent = Source.FadeExponent; angle = Source.AllowPartRotation ? float.DegreesToRadians(Source.PartRotation) : 0; + cursorScale = Source.cursorScale; originPosition = Vector2.Zero; @@ -307,7 +321,9 @@ protected override void Draw(IRenderer renderer) vertexBatch.Add(new TexturedTrailVertex { Position = rotateAround( - new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), + new Vector2( + part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X * cursorScale.X, + part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y * cursorScale.Y), part.Position, sin, cos), TexturePosition = textureRect.BottomLeft, TextureRect = new Vector4(0, 0, 1, 1), @@ -318,8 +334,10 @@ protected override void Draw(IRenderer renderer) vertexBatch.Add(new TexturedTrailVertex { Position = rotateAround( - new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, - part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), part.Position, sin, cos), + new Vector2( + part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X * cursorScale.X, + part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y * cursorScale.Y), + part.Position, sin, cos), TexturePosition = textureRect.BottomRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomRight.Linear, @@ -329,7 +347,9 @@ protected override void Draw(IRenderer renderer) vertexBatch.Add(new TexturedTrailVertex { Position = rotateAround( - new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + new Vector2( + part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X * cursorScale.X, + part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y * cursorScale.Y), part.Position, sin, cos), TexturePosition = textureRect.TopRight, TextureRect = new Vector4(0, 0, 1, 1), @@ -340,7 +360,9 @@ protected override void Draw(IRenderer renderer) vertexBatch.Add(new TexturedTrailVertex { Position = rotateAround( - new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + new Vector2( + part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X * cursorScale.X, + part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y * cursorScale.Y), part.Position, sin, cos), TexturePosition = textureRect.TopLeft, TextureRect = new Vector4(0, 0, 1, 1), diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index 974d99d7c868..e04382d194d4 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -64,8 +64,14 @@ protected override void LoadComplete() var newScale = new Vector2(e.NewValue); rippleVisualiser.CursorScale = newScale; - cursorTrail.Scale = newScale; + updateTrailScale(); }, true); + cursorTrail.OnSkinChanged += updateTrailScale; + } + + private void updateTrailScale() + { + if (cursorTrail.Drawable is CursorTrail trail) trail.CursorScale = new Vector2(ActiveCursor.CursorScale.Value); } private int downCount; diff --git a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs index 0e410dbf57d6..2382fe6d95ba 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs @@ -3,7 +3,9 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Osu.Configuration; @@ -27,32 +29,34 @@ private void load() Children = new Drawable[] { - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = RulesetSettingsStrings.SnakingInSliders, + Caption = RulesetSettingsStrings.SnakingInSliders, Current = config.GetBindable(OsuRulesetSetting.SnakingInSliders) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - ClassicDefault = false, - LabelText = RulesetSettingsStrings.SnakingOutSliders, + Caption = RulesetSettingsStrings.SnakingOutSliders, Current = config.GetBindable(OsuRulesetSetting.SnakingOutSliders) + }) + { + ApplyClassicDefault = c => ((IHasCurrentValue)c).Current.Value = false, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = RulesetSettingsStrings.CursorTrail, + Caption = RulesetSettingsStrings.CursorTrail, Current = config.GetBindable(OsuRulesetSetting.ShowCursorTrail) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = RulesetSettingsStrings.CursorRipples, + Caption = RulesetSettingsStrings.CursorRipples, Current = config.GetBindable(OsuRulesetSetting.ShowCursorRipples) - }, - new SettingsEnumDropdown + }), + new SettingsItemV2(new FormEnumDropdown { - LabelText = RulesetSettingsStrings.PlayfieldBorderStyle, + Caption = RulesetSettingsStrings.PlayfieldBorderStyle, Current = config.GetBindable(OsuRulesetSetting.PlayfieldBorderStyle), - }, + }), }; } } diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.cs index 1951d467e230..76de6c472402 100644 --- a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.Lines; using osu.Framework.Graphics.Performance; using osu.Game.Graphics; @@ -12,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis { - public partial class CursorPathContainer : Path + public partial class CursorPathContainer : SmoothPath { private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); private readonly SortedSet aliveEntries = new SortedSet(new AimLinePointComparator()); @@ -22,14 +21,13 @@ public CursorPathContainer() lifetimeManager.EntryBecameAlive += entryBecameAlive; lifetimeManager.EntryBecameDead += entryBecameDead; - PathRadius = 0.5f; + PathRadius = 1f; } [BackgroundDependencyLoader] private void load(OsuColour colours) { Colour = colours.Pink2; - BackgroundColour = colours.Pink2.Opacity(0); } protected override void Update() diff --git a/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs b/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs index 389440ba2dc5..ff28444e82b6 100644 --- a/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs @@ -1,9 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using osu.Framework.Graphics; +using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -19,17 +19,24 @@ namespace osu.Game.Rulesets.Osu.UI /// public partial class SmokeContainer : Container, IRequireHighFrequencyMousePosition, IKeyBindingHandler { + private DrawablePool segmentPool = null!; private SmokeSkinnableDrawable? currentSegmentSkinnable; private Vector2 lastMousePosition; public override bool ReceivePositionalInputAt(Vector2 _) => true; + [BackgroundDependencyLoader] + private void load() + { + AddInternal(segmentPool = new DrawablePool(10)); + } + public bool OnPressed(KeyBindingPressEvent e) { if (e.Action == OsuAction.Smoke) { - AddInternal(currentSegmentSkinnable = new SmokeSkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorSmoke), _ => new DefaultSmokeSegment())); + AddInternal(currentSegmentSkinnable = segmentPool.Get(segment => segment.Segment?.StartDrawing(Time.Current))); // Add initial position immediately. addPosition(); @@ -59,17 +66,19 @@ protected override bool OnMouseMove(MouseMoveEvent e) return base.OnMouseMove(e); } - private void addPosition() => (currentSegmentSkinnable?.Drawable as SmokeSegment)?.AddPosition(lastMousePosition, Time.Current); + private void addPosition() => currentSegmentSkinnable?.Segment?.AddPosition(lastMousePosition, Time.Current); private partial class SmokeSkinnableDrawable : SkinnableDrawable { + public SmokeSegment? Segment => Drawable as SmokeSegment; + public override bool RemoveWhenNotAlive => true; public override double LifetimeStart => Drawable.LifetimeStart; public override double LifetimeEnd => Drawable.LifetimeEnd; - public SmokeSkinnableDrawable(ISkinComponentLookup lookup, Func? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) - : base(lookup, defaultImplementation, confineMode) + public SmokeSkinnableDrawable() + : base(new OsuSkinComponentLookup(OsuSkinComponents.CursorSmoke), _ => new DefaultSmokeSegment()) { } } diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist index 162ee75c229f..1ef6b69ff395 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist @@ -35,11 +35,9 @@ UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft - XSAppIconAssets - Assets.xcassets/AppIcon.appiconset UIApplicationSupportsIndirectInputEvents CADisableMinimumFrameDurationOnPhone - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorSaving.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorSaving.cs index fb05502158df..ca99ae296cf9 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorSaving.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorSaving.cs @@ -35,7 +35,7 @@ public void TestTaikoSliderMultiplierInExport(float? multiplier) string export = LocalStorage.GetFiles("exports").First(); using (var stream = LocalStorage.GetStream(export)) - using (var zip = ZipArchive.Open(stream)) + using (var zip = ZipArchive.OpenArchive(stream)) { using (var osuStream = zip.Entries.First().OpenEntryStream()) using (var reader = new StreamReader(osuStream)) 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 e498989a792f..facb0d0cfbce 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 @@ -1,9 +1,9 @@  - - - + + + WinExe diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs index 3b3aea07f314..9cbc5bf2de77 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs @@ -5,9 +5,11 @@ using System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data; +using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators { @@ -16,8 +18,11 @@ public class RhythmEvaluator /// /// Evaluate the difficulty of a hitobject considering its interval change. /// - public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double hitWindow) + public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) { + if (hitObject.BaseObject is not Hit) + return 0; + TaikoRhythmData rhythmData = ((TaikoDifficultyHitObject)hitObject).RhythmData; double difficulty = 0.0d; @@ -25,6 +30,8 @@ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double double samePattern = 0; double intervalPenalty = 0; + double hitWindow = hitObject.HitWindow(HitResult.Great); + if (rhythmData.SameRhythmGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SameRhythmGroupedHitObjects { sameRhythm += 10.0 * evaluateDifficultyOf(rhythmData.SameRhythmGroupedHitObjects, hitWindow); @@ -56,8 +63,8 @@ private static double evaluateDifficultyOf(SameRhythmHitObjectGrouping sameRhyth { intervalDifficulty *= DifficultyCalculationUtils.Logistic( durationDifference / hitWindow, - midpointOffset: 0.7, - multiplier: 1.0, + midpointOffset: 0.35, + multiplier: 2, maxValue: 1); } } @@ -65,8 +72,8 @@ private static double evaluateDifficultyOf(SameRhythmHitObjectGrouping sameRhyth // Penalise patterns that can be hit within a single hit window. intervalDifficulty *= DifficultyCalculationUtils.Logistic( sameRhythmGroupedHitObjects.Duration / hitWindow, - midpointOffset: 0.6, - multiplier: 1, + midpointOffset: 0.3, + multiplier: 2, maxValue: 1); return Math.Pow(intervalDifficulty, 0.75); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs index f407e13ff1d6..f9bb38688d6f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -7,10 +7,10 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; -using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Utils; +using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing { diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index 45d0d0a5480f..e41f3ff5e975 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -17,17 +17,14 @@ public class Rhythm : StrainDecaySkill protected override double SkillMultiplier => 1.0; protected override double StrainDecayBase => 0.4; - private readonly double greatHitWindow; - - public Rhythm(Mod[] mods, double greatHitWindow) + public Rhythm(Mod[] mods) : base(mods) { - this.greatHitWindow = greatHitWindow; } protected override double StrainValueOf(DifficultyHitObject current) { - double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current, greatHitWindow); + double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current); // To prevent abuse of exceedingly long intervals between awkward rhythms, we penalise its difficulty. double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) - 0.5; // Remove base strain diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index edd26819f503..64af2861eca2 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -10,13 +10,12 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; -using osu.Game.Rulesets.Taiko.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Taiko.Difficulty { @@ -41,17 +40,14 @@ public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { - HitWindows hitWindows = new TaikoHitWindows(); - hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0; isRelax = mods.Any(h => h is TaikoModRelax); return new Skill[] { - new Rhythm(mods, hitWindows.WindowFor(HitResult.Great) / clockRate), + new Rhythm(mods), new Reading(mods), new Colour(mods), new Stamina(mods, false, isConvert), @@ -67,13 +63,15 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo new TaikoModHardRock(), }; - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { var difficultyHitObjects = new List(); var centreObjects = new List(); var rimObjects = new List(); var noteObjects = new List(); + double clockRate = ModUtils.CalculateRateWithMods(mods); + // Generate TaikoDifficultyHitObjects from the beatmap's hit objects. for (int i = 2; i < beatmap.HitObjects.Count; i++) { @@ -97,7 +95,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I return difficultyHitObjects; } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new TaikoDifficultyAttributes { Mods = mods }; @@ -108,14 +106,16 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var stamina = skills.OfType().Single(s => !s.SingleColourStamina); var singleColourStamina = skills.OfType().Single(s => s.SingleColourStamina); + double staminaDifficultyValue = stamina.DifficultyValue(); + double rhythmSkill = rhythm.DifficultyValue() * rhythm_skill_multiplier; double readingSkill = reading.DifficultyValue() * reading_skill_multiplier; double colourSkill = colour.DifficultyValue() * colour_skill_multiplier; - double staminaSkill = stamina.DifficultyValue() * stamina_skill_multiplier; + double staminaSkill = staminaDifficultyValue * stamina_skill_multiplier; double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5); - double staminaDifficultStrains = stamina.CountTopWeightedStrains(); + double staminaDifficultStrains = stamina.CountTopWeightedStrains(staminaDifficultyValue); // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10); @@ -184,10 +184,10 @@ private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour co } List hitObjectStrainPeaks = combinePeaks( - rhythm.GetObjectStrains().ToList(), - reading.GetObjectStrains().ToList(), - colour.GetObjectStrains().ToList(), - stamina.GetObjectStrains().ToList() + rhythm.GetObjectDifficulties(), + reading.GetObjectDifficulties(), + colour.GetObjectDifficulties(), + stamina.GetObjectDifficulties() ); if (hitObjectStrainPeaks.Count == 0) @@ -209,7 +209,7 @@ private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour co /// /// Combines lists of peak strains from multiple skills into a list of single peak strains for each section. /// - private List combinePeaks(List rhythmPeaks, List readingPeaks, List colourPeaks, List staminaPeaks) + private List combinePeaks(IReadOnlyList rhythmPeaks, IReadOnlyList readingPeaks, IReadOnlyList colourPeaks, IReadOnlyList staminaPeaks) { var combinedPeaks = new List(); diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index 3d5c95e1e8b6..6073dc791204 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -28,7 +28,7 @@ public partial class TaikoSpanPlacementBlueprint : HitObjectPlacementBlueprint [Resolved] private TaikoHitObjectComposer? composer { get; set; } - protected override bool IsValidForPlacement => Precision.DefinitelyBigger(spanPlacementObject.Duration, 0); + protected override bool IsValidForPlacement => base.IsValidForPlacement && (PlacementActive == PlacementState.Waiting || Precision.DefinitelyBigger(spanPlacementObject.Duration, 0)); public TaikoSpanPlacementBlueprint(HitObject hitObject) : base(hitObject) diff --git a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs index 8ef911c18e9f..30717d7ee90f 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs @@ -13,9 +13,9 @@ public class CheckTaikoLowestDiffDrainTime : CheckLowestDiffDrainTime protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() { // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21taiko#general - yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Muzukashii"); - yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Oni"); - yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Inner Oni"); + yield return (DifficultyRating.Hard, new TimeSpan(0, 2, 30).TotalMilliseconds, "Muzukashii"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 3, 15).TotalMilliseconds, "Oni"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 4, 0).TotalMilliseconds, "Inner Oni"); } } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs index 2132121cd248..dfaae8e99e7d 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs @@ -122,6 +122,8 @@ void processPattern(int patternEndIndex) } } } + + taikoBeatmap.HitObjects.Sort((a, b) => a.StartTime.CompareTo(b.StartTime)); } private int getSnapBetweenNotes(ControlPointInfo controlPointInfo, Hit currentNote, Hit nextNote) diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs index 43c870856597..4b6a9780a3d7 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs @@ -29,6 +29,7 @@ public partial class TaikoModSingleTap : Mod, IApplicableToDrawableRuleset OsuIcon.ModSingleTap; public override LocalisableString Description => @"One key for dons, one key for kats."; + public override bool Ranked => true; public override double ScoreMultiplier => 1.0; public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(TaikoModCinema) }; public override ModType Type => ModType.Conversion; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs index f1feb8153a6b..9af3eedf9343 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs @@ -22,6 +22,7 @@ public class TaikoModSwap : Mod, IApplicableToBeatmap public override ModType Type => ModType.Conversion; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModRandom)).ToArray(); + public override bool Ranked => true; public void ApplyToBeatmap(IBeatmap beatmap) { diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 547d0afe4afd..f4dc1f18bdfc 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -40,6 +40,7 @@ public partial class DrawableDrumRoll : DrawableTaikoStrongableHitObject new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.DrumRollBody), _ => new ElongatedCirclePiece()); + private SkinnableDrawable createHeadPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.DrumRollHead), _ => Empty()) + { + RelativeSizeAxes = Axes.Y, + Depth = -2, + }; + public override bool OnPressed(KeyBindingPressEvent e) => false; private void onNewResult(DrawableHitObject obj, JudgementResult result) @@ -174,7 +187,23 @@ protected override void Update() private void updateColour(double fadeDuration = 0) { Color4 newColour = Interpolation.ValueAt((float)rollingHits / rolling_hits_for_engaged_colour, colourIdle, colourEngaged, 0, 1); - (MainPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, fadeDuration); + + if (fadeDuration == 0) + { + // fade duration is 0 when calling via `RecreatePieces()`. + // in this case we want to apply the colour *without* using transforms. + // using transforms may result in the application of colour being undone via `DrawableHitObject.UpdateState()` clearing transforms. + if (MainPiece.Drawable is IHasAccentColour mainPieceWithAccentColour) + mainPieceWithAccentColour.AccentColour = newColour; + + if (headPiece.Drawable is IHasAccentColour headPieceWithAccentColour) + headPieceWithAccentColour.AccentColour = newColour; + } + else + { + (MainPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, fadeDuration); + (headPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, fadeDuration); + } } public partial class StrongNestedHit : DrawableStrongNestedHit diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs index b0a1c5d3f7f6..820af4ce54f8 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs @@ -51,6 +51,9 @@ public TaikoArgonSkinTransformer(ISkin skin) spectatorList.Anchor = Anchor.BottomLeft; spectatorList.Origin = Anchor.TopLeft; } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/TaikoTrianglesSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/TaikoTrianglesSkinTransformer.cs index f6274178893e..1a73457c67e7 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/TaikoTrianglesSkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/TaikoTrianglesSkinTransformer.cs @@ -51,6 +51,9 @@ public TaikoTrianglesSkinTransformer(ISkin skin) spectatorList.Origin = Anchor.TopLeft; spectatorList.Position = new Vector2(320, -280); } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs index 78be0ef64344..34339b185dbc 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -21,14 +20,10 @@ public override Quad ScreenSpaceDrawQuad { get { - // the reason why this calculation is so involved is that the head & tail sprites have different sizes/radii. - // therefore naively taking the SSDQs of them and making a quad out of them results in a trapezoid shape and not a box. - var headCentre = headCircle.ScreenSpaceDrawQuad.Centre; + var headCentre = (body.ScreenSpaceDrawQuad.TopLeft + body.ScreenSpaceDrawQuad.BottomLeft) / 2; var tailCentre = (tailCircle.ScreenSpaceDrawQuad.TopLeft + tailCircle.ScreenSpaceDrawQuad.BottomLeft) / 2; - float headRadius = headCircle.ScreenSpaceDrawQuad.Height / 2; - float tailRadius = tailCircle.ScreenSpaceDrawQuad.Height / 2; - float radius = Math.Max(headRadius, tailRadius); + float radius = body.ScreenSpaceDrawQuad.Height / 2; var rectangle = new RectangleF(headCentre.X, headCentre.Y, tailCentre.X - headCentre.X, 0).Inflate(radius); return new Quad(rectangle.TopLeft, rectangle.TopRight, rectangle.BottomLeft, rectangle.BottomRight); @@ -37,8 +32,6 @@ public override Quad ScreenSpaceDrawQuad public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => ScreenSpaceDrawQuad.Contains(screenSpacePos); - private LegacyCirclePiece headCircle = null!; - private Sprite body = null!; private Sprite tailCircle = null!; @@ -66,10 +59,6 @@ private void load(ISkinSource skin, OsuColour colours) RelativeSizeAxes = Axes.Both, Texture = skin.GetTexture("taiko-roll-middle", WrapMode.ClampToEdge, WrapMode.ClampToEdge), }, - headCircle = new LegacyCirclePiece - { - RelativeSizeAxes = Axes.Y, - }, }; AccentColour = colours.YellowDark; @@ -101,7 +90,6 @@ private void updateAccentColour() { var colour = LegacyColourCompatibility.DisallowZeroAlpha(accentColour); - headCircle.AccentColour = colour; body.Colour = colour; tailCircle.Colour = colour; } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index b5c767c2befe..a985bd362e4e 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -79,6 +79,9 @@ public TaikoLegacySkinTransformer(ISkin skin) spectatorList.Origin = Anchor.TopLeft; spectatorList.Position = pos; } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { new LegacyDefaultComboCounter(), @@ -103,6 +106,12 @@ public TaikoLegacySkinTransformer(ISkin skin) { switch (taikoComponent.Component) { + case TaikoSkinComponents.DrumRollHead: + if (GetTexture("taiko-roll-middle") != null) + return new LegacyCirclePiece(); + + return null; + case TaikoSkinComponents.DrumRollBody: if (GetTexture("taiko-roll-middle") != null) return new LegacyDrumRoll(); diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index b25672f719bb..686d40ff37da 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -222,15 +222,18 @@ public override IEnumerable CreateEditorSetupSections() => public override RulesetSettingsSubsection CreateSettings() => new TaikoSettingsSubsection(this); - protected override IEnumerable GetValidHitResults() + public override IEnumerable GetValidHitResults() { return new[] { HitResult.Great, HitResult.Ok, + HitResult.Miss, HitResult.SmallBonus, HitResult.LargeBonus, + HitResult.IgnoreHit, + HitResult.IgnoreMiss, }; } diff --git a/osu.Game.Rulesets.Taiko/TaikoSettingsSubsection.cs b/osu.Game.Rulesets.Taiko/TaikoSettingsSubsection.cs index 84dea474c545..58fb6a024661 100644 --- a/osu.Game.Rulesets.Taiko/TaikoSettingsSubsection.cs +++ b/osu.Game.Rulesets.Taiko/TaikoSettingsSubsection.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Taiko.Configuration; @@ -26,11 +27,11 @@ private void load() Children = new Drawable[] { - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = RulesetSettingsStrings.TouchControlScheme, + Caption = RulesetSettingsStrings.TouchControlScheme, Current = config.GetBindable(TaikoRulesetSetting.TouchControlScheme) - } + }) }; } } diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs index 28133ffcb28e..31342b30c411 100644 --- a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs +++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs @@ -8,6 +8,7 @@ public enum TaikoSkinComponents InputDrum, CentreHit, RimHit, + DrumRollHead, DrumRollBody, DrumRollTick, Swell, diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index bdcb341fb4ef..db61f27cb2e3 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -194,7 +194,7 @@ private void load(OsuColour colours) var hitWindows = new TaikoHitWindows(); - HitResult[] usableHitResults = Enum.GetValues().Where(r => hitWindows.IsHitResultAllowed(r)).ToArray(); + HitResult[] usableHitResults = Enum.GetValues().Where(hitWindows.IsHitResultAllowed).ToArray(); AddInternal(judgementPooler = new JudgementPooler(usableHitResults)); diff --git a/osu.Game.Tests.iOS/Info.plist b/osu.Game.Tests.iOS/Info.plist index d2d0583e46cb..e08b9bf6e66f 100644 --- a/osu.Game.Tests.iOS/Info.plist +++ b/osu.Game.Tests.iOS/Info.plist @@ -35,8 +35,31 @@ UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft - XSAppIconAssets - Assets.xcassets/AppIcon.appiconset + CFBundleIcons~ipad + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon60x60 + + CFBundleIconName + AppIcon + + + CFBundleIcons + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon60x60 + AppIcon76x76 + + CFBundleIconName + AppIcon + + UIApplicationSupportsIndirectInputEvents CADisableMinimumFrameDurationOnPhone diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 916e1e757a94..51875a25d93e 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; @@ -42,9 +43,9 @@ public void TestDecodeBeatmapVersion() var decoder = Decoder.GetDecoder(stream); var working = new TestWorkingBeatmap(decoder.Decode(stream)); - Assert.AreEqual(6, working.Beatmap.BeatmapVersion); + ClassicAssert.AreEqual(6, working.Beatmap.BeatmapVersion); Assert.That(working.Beatmap.BeatmapInfo.Ruleset.Name, Is.Not.EqualTo("null placeholder ruleset")); - Assert.AreEqual(6, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion); + ClassicAssert.AreEqual(6, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion); } } @@ -59,10 +60,10 @@ public void TestPreviewPointWithOffsets(bool applyOffsets) ((LegacyBeatmapDecoder)decoder).ApplyOffsets = applyOffsets; var working = new TestWorkingBeatmap(decoder.Decode(stream)); - Assert.AreEqual(4, working.Beatmap.BeatmapVersion); - Assert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion); + ClassicAssert.AreEqual(4, working.Beatmap.BeatmapVersion); + ClassicAssert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion); - Assert.AreEqual(-1, working.BeatmapInfo.Metadata.PreviewTime); + ClassicAssert.AreEqual(-1, working.BeatmapInfo.Metadata.PreviewTime); } } @@ -78,17 +79,17 @@ public void TestDecodeBeatmapGeneral() var beatmapInfo = beatmap.BeatmapInfo; var metadata = beatmap.Metadata; - Assert.AreEqual("03. Renatus - Soleily 192kbps.mp3", metadata.AudioFile); - Assert.AreEqual(0, beatmap.AudioLeadIn); - Assert.AreEqual(164471, metadata.PreviewTime); - Assert.AreEqual(0.7f, beatmap.StackLeniency); - Assert.IsTrue(beatmapInfo.Ruleset.OnlineID == 0); - Assert.IsFalse(beatmap.LetterboxInBreaks); - Assert.IsFalse(beatmap.SpecialStyle); - Assert.IsFalse(beatmap.WidescreenStoryboard); - Assert.IsFalse(beatmap.SamplesMatchPlaybackRate); - Assert.AreEqual(CountdownType.None, beatmap.Countdown); - Assert.AreEqual(0, beatmap.CountdownOffset); + ClassicAssert.AreEqual("03. Renatus - Soleily 192kbps.mp3", metadata.AudioFile); + ClassicAssert.AreEqual(0, beatmap.AudioLeadIn); + ClassicAssert.AreEqual(164471, metadata.PreviewTime); + ClassicAssert.AreEqual(0.7f, beatmap.StackLeniency); + ClassicAssert.True(beatmapInfo.Ruleset.OnlineID == 0); + ClassicAssert.False(beatmap.LetterboxInBreaks); + ClassicAssert.False(beatmap.SpecialStyle); + ClassicAssert.False(beatmap.WidescreenStoryboard); + ClassicAssert.False(beatmap.SamplesMatchPlaybackRate); + ClassicAssert.AreEqual(CountdownType.None, beatmap.Countdown); + ClassicAssert.AreEqual(0, beatmap.CountdownOffset); } } @@ -108,13 +109,13 @@ public void TestDecodeBeatmapEditor() 95901, 106450, 116999, 119637, 130186, 140735, 151285, 161834, 164471, 175020, 185570, 196119, 206669, 209306 }; - Assert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length); + ClassicAssert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length); for (int i = 0; i < expectedBookmarks.Length; i++) - Assert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]); - Assert.AreEqual(1.8, beatmap.DistanceSpacing); - Assert.AreEqual(4, beatmap.BeatmapInfo.BeatDivisor); - Assert.AreEqual(4, beatmap.GridSize); - Assert.AreEqual(2, beatmap.TimelineZoom); + ClassicAssert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]); + ClassicAssert.AreEqual(1.8, beatmap.DistanceSpacing); + ClassicAssert.AreEqual(4, beatmap.BeatmapInfo.BeatDivisor); + ClassicAssert.AreEqual(4, beatmap.GridSize); + ClassicAssert.AreEqual(2, beatmap.TimelineZoom); } } @@ -130,16 +131,16 @@ public void TestDecodeBeatmapMetadata() var beatmapInfo = beatmap.BeatmapInfo; var metadata = beatmap.Metadata; - Assert.AreEqual("Renatus", metadata.Title); - Assert.AreEqual("Renatus", metadata.TitleUnicode); - Assert.AreEqual("Soleily", metadata.Artist); - Assert.AreEqual("Soleily", metadata.ArtistUnicode); - Assert.AreEqual("Gamu", metadata.Author.Username); - Assert.AreEqual("Insane", beatmapInfo.DifficultyName); - Assert.AreEqual(string.Empty, metadata.Source); - Assert.AreEqual("MBC7 Unisphere 地球ヤバイEP Chikyu Yabai", metadata.Tags); - Assert.AreEqual(557821, beatmapInfo.OnlineID); - Assert.AreEqual(241526, beatmapInfo.BeatmapSet?.OnlineID); + ClassicAssert.AreEqual("Renatus", metadata.Title); + ClassicAssert.AreEqual("Renatus", metadata.TitleUnicode); + ClassicAssert.AreEqual("Soleily", metadata.Artist); + ClassicAssert.AreEqual("Soleily", metadata.ArtistUnicode); + ClassicAssert.AreEqual("Gamu", metadata.Author.Username); + ClassicAssert.AreEqual("Insane", beatmapInfo.DifficultyName); + ClassicAssert.AreEqual(string.Empty, metadata.Source); + ClassicAssert.AreEqual("MBC7 Unisphere 地球ヤバイEP Chikyu Yabai", metadata.Tags); + ClassicAssert.AreEqual(557821, beatmapInfo.OnlineID); + ClassicAssert.AreEqual(241526, beatmapInfo.BeatmapSet?.OnlineID); } } @@ -153,12 +154,12 @@ public void TestDecodeBeatmapDifficulty() { var difficulty = decoder.Decode(stream).Difficulty; - Assert.AreEqual(6.5f, difficulty.DrainRate); - Assert.AreEqual(4, difficulty.CircleSize); - Assert.AreEqual(8, difficulty.OverallDifficulty); - Assert.AreEqual(9, difficulty.ApproachRate); - Assert.AreEqual(1.8, difficulty.SliderMultiplier); - Assert.AreEqual(2, difficulty.SliderTickRate); + ClassicAssert.AreEqual(6.5f, difficulty.DrainRate); + ClassicAssert.AreEqual(4, difficulty.CircleSize); + ClassicAssert.AreEqual(8, difficulty.OverallDifficulty); + ClassicAssert.AreEqual(9, difficulty.ApproachRate); + ClassicAssert.AreEqual(1.8, difficulty.SliderMultiplier); + ClassicAssert.AreEqual(2, difficulty.SliderTickRate); } } @@ -174,10 +175,10 @@ public void TestDecodeBeatmapEvents() var metadata = beatmap.Metadata; var breakPoint = beatmap.Breaks[0]; - Assert.AreEqual("machinetop_background.jpg", metadata.BackgroundFile); - Assert.AreEqual(122474, breakPoint.StartTime); - Assert.AreEqual(140135, breakPoint.EndTime); - Assert.IsTrue(breakPoint.HasEffect); + ClassicAssert.AreEqual("machinetop_background.jpg", metadata.BackgroundFile); + ClassicAssert.AreEqual(122474, breakPoint.StartTime); + ClassicAssert.AreEqual(140135, breakPoint.EndTime); + ClassicAssert.True(breakPoint.HasEffect); } } @@ -192,7 +193,7 @@ public void TestDecodeVideoWithLowercaseExtension() var beatmap = decoder.Decode(stream); var metadata = beatmap.Metadata; - Assert.AreEqual("BG.jpg", metadata.BackgroundFile); + ClassicAssert.AreEqual("BG.jpg", metadata.BackgroundFile); } } @@ -207,7 +208,7 @@ public void TestDecodeVideoWithUppercaseExtension() var beatmap = decoder.Decode(stream); var metadata = beatmap.Metadata; - Assert.AreEqual("BG.jpg", metadata.BackgroundFile); + ClassicAssert.AreEqual("BG.jpg", metadata.BackgroundFile); } } @@ -222,7 +223,7 @@ public void TestDecodeImageSpecifiedAsVideo() var beatmap = decoder.Decode(stream); var metadata = beatmap.Metadata; - Assert.AreEqual("BG.jpg", metadata.BackgroundFile); + ClassicAssert.AreEqual("BG.jpg", metadata.BackgroundFile); } } @@ -237,67 +238,67 @@ public void TestDecodeBeatmapTimingPoints() var beatmap = decoder.Decode(stream); var controlPoints = (LegacyControlPointInfo)beatmap.ControlPointInfo; - Assert.AreEqual(4, controlPoints.TimingPoints.Count); - Assert.AreEqual(5, controlPoints.DifficultyPoints.Count); - Assert.AreEqual(34, controlPoints.SamplePoints.Count); - Assert.AreEqual(8, controlPoints.EffectPoints.Count); + ClassicAssert.AreEqual(4, controlPoints.TimingPoints.Count); + ClassicAssert.AreEqual(5, controlPoints.DifficultyPoints.Count); + ClassicAssert.AreEqual(34, controlPoints.SamplePoints.Count); + ClassicAssert.AreEqual(8, controlPoints.EffectPoints.Count); var timingPoint = controlPoints.TimingPointAt(0); - Assert.AreEqual(956, timingPoint.Time); - Assert.AreEqual(329.67032967033, timingPoint.BeatLength); - Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature); - Assert.IsFalse(timingPoint.OmitFirstBarLine); + ClassicAssert.AreEqual(956, timingPoint.Time); + ClassicAssert.AreEqual(329.67032967033, timingPoint.BeatLength); + ClassicAssert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature); + ClassicAssert.False(timingPoint.OmitFirstBarLine); timingPoint = controlPoints.TimingPointAt(48428); - Assert.AreEqual(956, timingPoint.Time); - Assert.AreEqual(329.67032967033d, timingPoint.BeatLength); - Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature); - Assert.IsFalse(timingPoint.OmitFirstBarLine); + ClassicAssert.AreEqual(956, timingPoint.Time); + ClassicAssert.AreEqual(329.67032967033d, timingPoint.BeatLength); + ClassicAssert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature); + ClassicAssert.False(timingPoint.OmitFirstBarLine); timingPoint = controlPoints.TimingPointAt(119637); - Assert.AreEqual(119637, timingPoint.Time); - Assert.AreEqual(659.340659340659, timingPoint.BeatLength); - Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature); - Assert.IsFalse(timingPoint.OmitFirstBarLine); + ClassicAssert.AreEqual(119637, timingPoint.Time); + ClassicAssert.AreEqual(659.340659340659, timingPoint.BeatLength); + ClassicAssert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature); + ClassicAssert.False(timingPoint.OmitFirstBarLine); var difficultyPoint = controlPoints.DifficultyPointAt(0); - Assert.AreEqual(0, difficultyPoint.Time); - Assert.AreEqual(1.0, difficultyPoint.SliderVelocity); + ClassicAssert.AreEqual(0, difficultyPoint.Time); + ClassicAssert.AreEqual(1.0, difficultyPoint.SliderVelocity); difficultyPoint = controlPoints.DifficultyPointAt(48428); - Assert.AreEqual(0, difficultyPoint.Time); - Assert.AreEqual(1.0, difficultyPoint.SliderVelocity); + ClassicAssert.AreEqual(0, difficultyPoint.Time); + ClassicAssert.AreEqual(1.0, difficultyPoint.SliderVelocity); difficultyPoint = controlPoints.DifficultyPointAt(116999); - Assert.AreEqual(116999, difficultyPoint.Time); - Assert.AreEqual(0.75, difficultyPoint.SliderVelocity, 0.1); + ClassicAssert.AreEqual(116999, difficultyPoint.Time); + ClassicAssert.AreEqual(0.75, difficultyPoint.SliderVelocity, 0.1); var soundPoint = controlPoints.SamplePointAt(0); - Assert.AreEqual(956, soundPoint.Time); - Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); - Assert.AreEqual(60, soundPoint.SampleVolume); + ClassicAssert.AreEqual(956, soundPoint.Time); + ClassicAssert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); + ClassicAssert.AreEqual(60, soundPoint.SampleVolume); soundPoint = controlPoints.SamplePointAt(53373); - Assert.AreEqual(53373, soundPoint.Time); - Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); - Assert.AreEqual(60, soundPoint.SampleVolume); + ClassicAssert.AreEqual(53373, soundPoint.Time); + ClassicAssert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); + ClassicAssert.AreEqual(60, soundPoint.SampleVolume); soundPoint = controlPoints.SamplePointAt(119637); - Assert.AreEqual(119637, soundPoint.Time); - Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); - Assert.AreEqual(80, soundPoint.SampleVolume); + ClassicAssert.AreEqual(119637, soundPoint.Time); + ClassicAssert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); + ClassicAssert.AreEqual(80, soundPoint.SampleVolume); var effectPoint = controlPoints.EffectPointAt(0); - Assert.AreEqual(0, effectPoint.Time); - Assert.IsFalse(effectPoint.KiaiMode); + ClassicAssert.AreEqual(0, effectPoint.Time); + ClassicAssert.False(effectPoint.KiaiMode); effectPoint = controlPoints.EffectPointAt(53703); - Assert.AreEqual(53703, effectPoint.Time); - Assert.IsTrue(effectPoint.KiaiMode); + ClassicAssert.AreEqual(53703, effectPoint.Time); + ClassicAssert.True(effectPoint.KiaiMode); effectPoint = controlPoints.EffectPointAt(116637); - Assert.AreEqual(95901, effectPoint.Time); - Assert.IsFalse(effectPoint.KiaiMode); + ClassicAssert.AreEqual(95901, effectPoint.Time); + ClassicAssert.False(effectPoint.KiaiMode); } } @@ -397,9 +398,9 @@ public void TestDecodeBeatmapColours() new Color4(255, 177, 140, 255), new Color4(100, 100, 100, 255), // alpha is specified as 100, but should be ignored. }; - Assert.AreEqual(expectedColors.Length, comboColors.Count); + ClassicAssert.AreEqual(expectedColors.Length, comboColors.Count); for (int i = 0; i < expectedColors.Length; i++) - Assert.AreEqual(expectedColors[i], comboColors[i]); + ClassicAssert.AreEqual(expectedColors[i], comboColors[i]); } } @@ -426,9 +427,9 @@ public void TestComboColourCountIsLimitedToEight() new Color4(100, 100, 100, 255), new Color4(142, 199, 255, 255), }; - Assert.AreEqual(expectedColors.Length, comboColors.Count); + ClassicAssert.AreEqual(expectedColors.Length, comboColors.Count); for (int i = 0; i < expectedColors.Length; i++) - Assert.AreEqual(expectedColors[i], comboColors[i]); + ClassicAssert.AreEqual(expectedColors[i], comboColors[i]); } } @@ -464,12 +465,12 @@ public void TestDecodeBeatmapComboOffsetsOsu() new OsuBeatmapProcessor(converted).PreProcess(); new OsuBeatmapProcessor(converted).PostProcess(); - Assert.AreEqual(1, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets); - Assert.AreEqual(2, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets); - Assert.AreEqual(3, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets); - Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets); - Assert.AreEqual(8, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets); - Assert.AreEqual(9, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(1, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(2, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(3, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(8, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(9, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets); } } @@ -487,12 +488,12 @@ public void TestDecodeBeatmapComboOffsetsCatch() new CatchBeatmapProcessor(converted).PreProcess(); new CatchBeatmapProcessor(converted).PostProcess(); - Assert.AreEqual(1, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets); - Assert.AreEqual(2, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets); - Assert.AreEqual(3, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets); - Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets); - Assert.AreEqual(8, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets); - Assert.AreEqual(9, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(1, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(2, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(3, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(8, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets); + ClassicAssert.AreEqual(9, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets); } } @@ -508,8 +509,8 @@ public void TestDecodeBeatmapHitObjectCoordinatesLegacy() var positionData = hitObjects[0] as IHasPosition; - Assert.IsNotNull(positionData); - Assert.AreEqual(new Vector2(256, 256), positionData!.Position); + ClassicAssert.NotNull(positionData); + ClassicAssert.AreEqual(new Vector2(256, 256), positionData!.Position); } } @@ -525,8 +526,8 @@ public void TestDecodeBeatmapHitObjectCoordinatesLazer() var positionData = hitObjects[0] as IHasPosition; - Assert.IsNotNull(positionData); - Assert.AreEqual(new Vector2(256.99853f, 256.001f), positionData!.Position); + ClassicAssert.NotNull(positionData); + ClassicAssert.AreEqual(new Vector2(256.99853f, 256.001f), positionData!.Position); } } @@ -543,18 +544,18 @@ public void TestDecodeBeatmapHitObjects() var curveData = hitObjects[0] as IHasPathWithRepeats; var positionData = hitObjects[0] as IHasPosition; - Assert.IsNotNull(positionData); - Assert.IsNotNull(curveData); - Assert.AreEqual(new Vector2(192, 168), positionData!.Position); - Assert.AreEqual(956, hitObjects[0].StartTime); - Assert.IsTrue(hitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL)); + ClassicAssert.NotNull(positionData); + ClassicAssert.NotNull(curveData); + ClassicAssert.AreEqual(new Vector2(192, 168), positionData!.Position); + ClassicAssert.AreEqual(956, hitObjects[0].StartTime); + ClassicAssert.True(hitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL)); positionData = hitObjects[1] as IHasPosition; - Assert.IsNotNull(positionData); - Assert.AreEqual(new Vector2(304, 56), positionData!.Position); - Assert.AreEqual(1285, hitObjects[1].StartTime); - Assert.IsTrue(hitObjects[1].Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)); + ClassicAssert.NotNull(positionData); + ClassicAssert.AreEqual(new Vector2(304, 56), positionData!.Position); + ClassicAssert.AreEqual(1285, hitObjects[1].StartTime); + ClassicAssert.True(hitObjects[1].Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)); } } @@ -585,22 +586,22 @@ public void TestDecodeControlPointCustomSampleBank() { var hitObjects = decoder.Decode(stream).HitObjects; - Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); - Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); - Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); - Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[3]).LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[3]).LookupNames.First()); // The fourth object is a slider. // `Samples` of a slider are presumed to control the volume of sounds that last the entire duration of the slider // (such as ticks, slider slide sounds, etc.) // Thus, the point of query of control points used for `Samples` is just beyond the start time of the slider. - Assert.AreEqual("Gameplay/soft-hitnormal11", getTestableSampleInfo(hitObjects[4]).LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/soft-hitnormal11", getTestableSampleInfo(hitObjects[4]).LookupNames.First()); // That said, the `NodeSamples` of the slider are responsible for the sounds of the slider's head / tail / repeats / large ticks etc. // Therefore, they should be read at the time instant correspondent to the given node. // This means that the tail should use bank 8 rather than 11. - Assert.AreEqual("Gameplay/soft-hitnormal11", ((ConvertSlider)hitObjects[4]).NodeSamples[0][0].LookupNames.First()); - Assert.AreEqual("Gameplay/soft-hitnormal8", ((ConvertSlider)hitObjects[4]).NodeSamples[1][0].LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/soft-hitnormal11", ((ConvertSlider)hitObjects[4]).NodeSamples[0][0].LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/soft-hitnormal8", ((ConvertSlider)hitObjects[4]).NodeSamples[1][0].LookupNames.First()); } static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0]; @@ -616,9 +617,9 @@ public void TestDecodeHitObjectCustomSampleBank() { var hitObjects = decoder.Decode(stream).HitObjects; - Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); - Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); - Assert.AreEqual("Gameplay/normal-hitnormal3", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/normal-hitnormal3", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); } static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0]; @@ -634,11 +635,11 @@ public void TestDecodeHitObjectFileSamples() { var hitObjects = decoder.Decode(stream).HitObjects; - Assert.AreEqual("hit_1.wav", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); - Assert.AreEqual("hit_2.wav", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); - Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); - Assert.AreEqual("hit_1.wav", getTestableSampleInfo(hitObjects[3]).LookupNames.First()); - Assert.AreEqual(70, getTestableSampleInfo(hitObjects[3]).Volume); + ClassicAssert.AreEqual("hit_1.wav", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); + ClassicAssert.AreEqual("hit_2.wav", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); + ClassicAssert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); + ClassicAssert.AreEqual("hit_1.wav", getTestableSampleInfo(hitObjects[3]).LookupNames.First()); + ClassicAssert.AreEqual(70, getTestableSampleInfo(hitObjects[3]).Volume); } static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0]; @@ -656,35 +657,35 @@ public void TestDecodeSliderSamples() var slider1 = (ConvertSlider)hitObjects[0]; - Assert.AreEqual(1, slider1.NodeSamples[0].Count); - Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider1.NodeSamples[0][0].Name); - Assert.AreEqual(1, slider1.NodeSamples[1].Count); - Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider1.NodeSamples[1][0].Name); - Assert.AreEqual(1, slider1.NodeSamples[2].Count); - Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider1.NodeSamples[2][0].Name); + ClassicAssert.AreEqual(1, slider1.NodeSamples[0].Count); + ClassicAssert.AreEqual(HitSampleInfo.HIT_NORMAL, slider1.NodeSamples[0][0].Name); + ClassicAssert.AreEqual(1, slider1.NodeSamples[1].Count); + ClassicAssert.AreEqual(HitSampleInfo.HIT_NORMAL, slider1.NodeSamples[1][0].Name); + ClassicAssert.AreEqual(1, slider1.NodeSamples[2].Count); + ClassicAssert.AreEqual(HitSampleInfo.HIT_NORMAL, slider1.NodeSamples[2][0].Name); var slider2 = (ConvertSlider)hitObjects[1]; - Assert.AreEqual(2, slider2.NodeSamples[0].Count); - Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider2.NodeSamples[0][0].Name); - Assert.AreEqual(HitSampleInfo.HIT_CLAP, slider2.NodeSamples[0][1].Name); - Assert.AreEqual(2, slider2.NodeSamples[1].Count); - Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider2.NodeSamples[1][0].Name); - Assert.AreEqual(HitSampleInfo.HIT_CLAP, slider2.NodeSamples[1][1].Name); - Assert.AreEqual(2, slider2.NodeSamples[2].Count); - Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider2.NodeSamples[2][0].Name); - Assert.AreEqual(HitSampleInfo.HIT_CLAP, slider2.NodeSamples[2][1].Name); + ClassicAssert.AreEqual(2, slider2.NodeSamples[0].Count); + ClassicAssert.AreEqual(HitSampleInfo.HIT_NORMAL, slider2.NodeSamples[0][0].Name); + ClassicAssert.AreEqual(HitSampleInfo.HIT_CLAP, slider2.NodeSamples[0][1].Name); + ClassicAssert.AreEqual(2, slider2.NodeSamples[1].Count); + ClassicAssert.AreEqual(HitSampleInfo.HIT_NORMAL, slider2.NodeSamples[1][0].Name); + ClassicAssert.AreEqual(HitSampleInfo.HIT_CLAP, slider2.NodeSamples[1][1].Name); + ClassicAssert.AreEqual(2, slider2.NodeSamples[2].Count); + ClassicAssert.AreEqual(HitSampleInfo.HIT_NORMAL, slider2.NodeSamples[2][0].Name); + ClassicAssert.AreEqual(HitSampleInfo.HIT_CLAP, slider2.NodeSamples[2][1].Name); var slider3 = (ConvertSlider)hitObjects[2]; - Assert.AreEqual(2, slider3.NodeSamples[0].Count); - Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider3.NodeSamples[0][0].Name); - Assert.AreEqual(HitSampleInfo.HIT_WHISTLE, slider3.NodeSamples[0][1].Name); - Assert.AreEqual(1, slider3.NodeSamples[1].Count); - Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider3.NodeSamples[1][0].Name); - Assert.AreEqual(2, slider3.NodeSamples[2].Count); - Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider3.NodeSamples[2][0].Name); - Assert.AreEqual(HitSampleInfo.HIT_CLAP, slider3.NodeSamples[2][1].Name); + ClassicAssert.AreEqual(2, slider3.NodeSamples[0].Count); + ClassicAssert.AreEqual(HitSampleInfo.HIT_NORMAL, slider3.NodeSamples[0][0].Name); + ClassicAssert.AreEqual(HitSampleInfo.HIT_WHISTLE, slider3.NodeSamples[0][1].Name); + ClassicAssert.AreEqual(1, slider3.NodeSamples[1].Count); + ClassicAssert.AreEqual(HitSampleInfo.HIT_NORMAL, slider3.NodeSamples[1][0].Name); + ClassicAssert.AreEqual(2, slider3.NodeSamples[2].Count); + ClassicAssert.AreEqual(HitSampleInfo.HIT_NORMAL, slider3.NodeSamples[2][0].Name); + ClassicAssert.AreEqual(HitSampleInfo.HIT_CLAP, slider3.NodeSamples[2][1].Name); } } @@ -698,7 +699,7 @@ public void TestDecodeHitObjectNullAdditionBank() { var hitObjects = decoder.Decode(stream).HitObjects; - Assert.AreEqual(hitObjects[0].Samples[0].Bank, hitObjects[0].Samples[1].Bank); + ClassicAssert.AreEqual(hitObjects[0].Samples[0].Bank, hitObjects[0].Samples[1].Bank); } } @@ -739,10 +740,10 @@ public void TestInvalidBankDefaultsToNormal() static void assertObjectHasBanks(HitObject hitObject, string normalBank, string? additionsBank = null) { - Assert.AreEqual(normalBank, hitObject.Samples[0].Bank); + ClassicAssert.AreEqual(normalBank, hitObject.Samples[0].Bank); if (additionsBank != null) - Assert.AreEqual(additionsBank, hitObject.Samples[1].Bank); + ClassicAssert.AreEqual(additionsBank, hitObject.Samples[1].Bank); } } @@ -756,11 +757,11 @@ public void TestFallbackDecoderForCorruptedHeader() using (var stream = new LineBufferedReader(resStream)) { Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); - Assert.IsInstanceOf(decoder); + ClassicAssert.IsInstanceOf(decoder); Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); - Assert.IsNotNull(beatmap); - Assert.AreEqual("Beatmap with corrupted header", beatmap.Metadata.Title); - Assert.AreEqual("Evil Hacker", beatmap.Metadata.Author.Username); + ClassicAssert.NotNull(beatmap); + ClassicAssert.AreEqual("Beatmap with corrupted header", beatmap.Metadata.Title); + ClassicAssert.AreEqual("Evil Hacker", beatmap.Metadata.Author.Username); } } @@ -774,11 +775,11 @@ public void TestFallbackDecoderForMissingHeader() using (var stream = new LineBufferedReader(resStream)) { Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); - Assert.IsInstanceOf(decoder); + ClassicAssert.IsInstanceOf(decoder); Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); - Assert.IsNotNull(beatmap); - Assert.AreEqual("Beatmap with no header", beatmap.Metadata.Title); - Assert.AreEqual("Incredibly Evil Hacker", beatmap.Metadata.Author.Username); + ClassicAssert.NotNull(beatmap); + ClassicAssert.AreEqual("Beatmap with no header", beatmap.Metadata.Title); + ClassicAssert.AreEqual("Incredibly Evil Hacker", beatmap.Metadata.Author.Username); } } @@ -792,11 +793,11 @@ public void TestDecodeFileWithEmptyLinesAtStart() using (var stream = new LineBufferedReader(resStream)) { Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); - Assert.IsInstanceOf(decoder); + ClassicAssert.IsInstanceOf(decoder); Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); - Assert.IsNotNull(beatmap); - Assert.AreEqual("Empty lines at start", beatmap.Metadata.Title); - Assert.AreEqual("Edge Case Hunter", beatmap.Metadata.Author.Username); + ClassicAssert.NotNull(beatmap); + ClassicAssert.AreEqual("Empty lines at start", beatmap.Metadata.Title); + ClassicAssert.AreEqual("Edge Case Hunter", beatmap.Metadata.Author.Username); } } @@ -810,11 +811,11 @@ public void TestDecodeFileWithEmptyLinesAndNoHeader() using (var stream = new LineBufferedReader(resStream)) { Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); - Assert.IsInstanceOf(decoder); + ClassicAssert.IsInstanceOf(decoder); Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); - Assert.IsNotNull(beatmap); - Assert.AreEqual("The dog ate the file header", beatmap.Metadata.Title); - Assert.AreEqual("Why does this keep happening", beatmap.Metadata.Author.Username); + ClassicAssert.NotNull(beatmap); + ClassicAssert.AreEqual("The dog ate the file header", beatmap.Metadata.Title); + ClassicAssert.AreEqual("Why does this keep happening", beatmap.Metadata.Author.Username); } } @@ -828,11 +829,11 @@ public void TestDecodeFileWithContentImmediatelyAfterHeader() using (var stream = new LineBufferedReader(resStream)) { Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); - Assert.IsInstanceOf(decoder); + ClassicAssert.IsInstanceOf(decoder); Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); - Assert.IsNotNull(beatmap); - Assert.AreEqual("No empty line delimiting header from contents", beatmap.Metadata.Title); - Assert.AreEqual("Edge Case Hunter", beatmap.Metadata.Author.Username); + ClassicAssert.NotNull(beatmap); + ClassicAssert.AreEqual("No empty line delimiting header from contents", beatmap.Metadata.Title); + ClassicAssert.AreEqual("Edge Case Hunter", beatmap.Metadata.Author.Username); } } @@ -855,7 +856,7 @@ public void TestAllowFallbackDecoderOverwrite() using (var stream = new LineBufferedReader(resStream)) { Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); - Assert.IsInstanceOf(decoder); + ClassicAssert.IsInstanceOf(decoder); } Assert.DoesNotThrow(LegacyDifficultyCalculatorBeatmapDecoder.Register); @@ -864,7 +865,7 @@ public void TestAllowFallbackDecoderOverwrite() using (var stream = new LineBufferedReader(resStream)) { Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); - Assert.IsInstanceOf(decoder); + ClassicAssert.IsInstanceOf(decoder); } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index 35ce73389511..c18bb9e90229 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; @@ -100,7 +101,7 @@ static ControlPointInfo removeLegacyControlPointTypes(ControlPointInfo controlPo { // emulate non-legacy control points by cloning the non-legacy portion. // the assertion is that the encoder can recreate this losslessly from hitobject data. - Assert.IsInstanceOf(controlPointInfo); + ClassicAssert.IsInstanceOf(controlPointInfo); var newControlPoints = new ControlPointInfo(); @@ -129,7 +130,7 @@ private void compareBeatmaps((IBeatmap beatmap, TestLegacySkin skin) expected, ( Assert.That(actual.beatmap.HitObjects.Serialize(), Is.EqualTo(expected.beatmap.HitObjects.Serialize())); // Check skin. - Assert.IsTrue(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration)); + ClassicAssert.True(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration)); } [Test] diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index 2815c9cd8fb3..1292d25d6ace 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; @@ -58,18 +59,18 @@ public void TestDecodeManiaReplay() { var score = decoder.Parse(resourceStream); - Assert.AreEqual(3, score.ScoreInfo.Ruleset.OnlineID); + ClassicAssert.AreEqual(3, score.ScoreInfo.Ruleset.OnlineID); - Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.Great]); - Assert.AreEqual(1, score.ScoreInfo.Statistics[HitResult.Good]); + ClassicAssert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.Great]); + ClassicAssert.AreEqual(1, score.ScoreInfo.Statistics[HitResult.Good]); - Assert.AreEqual(829_931, score.ScoreInfo.LegacyTotalScore); - Assert.AreEqual(3, score.ScoreInfo.MaxCombo); + ClassicAssert.AreEqual(829_931, score.ScoreInfo.LegacyTotalScore); + ClassicAssert.AreEqual(3, score.ScoreInfo.MaxCombo); Assert.That(score.ScoreInfo.APIMods.Select(m => m.Acronym), Is.EquivalentTo(new[] { "CL", "9K", "DS" })); Assert.That((2 * 300d + 1 * 200) / (3 * 305d), Is.EqualTo(score.ScoreInfo.Accuracy).Within(0.0001)); - Assert.AreEqual(ScoreRank.B, score.ScoreInfo.Rank); + ClassicAssert.AreEqual(ScoreRank.B, score.ScoreInfo.Rank); Assert.That(score.Replay.Frames, Has.One.Matches(frame => frame.Time == 414 && frame.Actions.SequenceEqual(new[] { ManiaAction.Key1, ManiaAction.Key18 }))); @@ -85,10 +86,10 @@ public void TestDecodeTaikoReplay() { var score = decoder.Parse(resourceStream); - Assert.AreEqual(1, score.ScoreInfo.Ruleset.OnlineID); - Assert.AreEqual(4, score.ScoreInfo.Statistics[HitResult.Great]); - Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.LargeBonus]); - Assert.AreEqual(4, score.ScoreInfo.MaxCombo); + ClassicAssert.AreEqual(1, score.ScoreInfo.Ruleset.OnlineID); + ClassicAssert.AreEqual(4, score.ScoreInfo.Statistics[HitResult.Great]); + ClassicAssert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.LargeBonus]); + ClassicAssert.AreEqual(4, score.ScoreInfo.MaxCombo); Assert.That(score.Replay.Frames, Is.Not.Empty); } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs index b10cce6a525e..9b76bd53ec24 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osuTK; using osu.Framework.Graphics; using osu.Game.Beatmaps.Formats; @@ -25,73 +26,73 @@ public void TestDecodeStoryboardEvents() { var storyboard = decoder.Decode(stream); - Assert.IsTrue(storyboard.HasDrawable); - Assert.AreEqual(6, storyboard.Layers.Count()); + ClassicAssert.True(storyboard.HasDrawable); + ClassicAssert.AreEqual(6, storyboard.Layers.Count()); StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); - Assert.IsNotNull(background); - Assert.AreEqual(16, background.Elements.Count); - Assert.IsTrue(background.VisibleWhenFailing); - Assert.IsTrue(background.VisibleWhenPassing); - Assert.AreEqual("Background", background.Name); + ClassicAssert.NotNull(background); + ClassicAssert.AreEqual(16, background.Elements.Count); + ClassicAssert.True(background.VisibleWhenFailing); + ClassicAssert.True(background.VisibleWhenPassing); + ClassicAssert.AreEqual("Background", background.Name); StoryboardLayer fail = storyboard.Layers.Single(l => l.Depth == 2); - Assert.IsNotNull(fail); - Assert.AreEqual(0, fail.Elements.Count); - Assert.IsTrue(fail.VisibleWhenFailing); - Assert.IsFalse(fail.VisibleWhenPassing); - Assert.AreEqual("Fail", fail.Name); + ClassicAssert.NotNull(fail); + ClassicAssert.AreEqual(0, fail.Elements.Count); + ClassicAssert.True(fail.VisibleWhenFailing); + ClassicAssert.False(fail.VisibleWhenPassing); + ClassicAssert.AreEqual("Fail", fail.Name); StoryboardLayer pass = storyboard.Layers.Single(l => l.Depth == 1); - Assert.IsNotNull(pass); - Assert.AreEqual(0, pass.Elements.Count); - Assert.IsFalse(pass.VisibleWhenFailing); - Assert.IsTrue(pass.VisibleWhenPassing); - Assert.AreEqual("Pass", pass.Name); + ClassicAssert.NotNull(pass); + ClassicAssert.AreEqual(0, pass.Elements.Count); + ClassicAssert.False(pass.VisibleWhenFailing); + ClassicAssert.True(pass.VisibleWhenPassing); + ClassicAssert.AreEqual("Pass", pass.Name); StoryboardLayer foreground = storyboard.Layers.Single(l => l.Depth == 0); - Assert.IsNotNull(foreground); - Assert.AreEqual(151, foreground.Elements.Count); - Assert.IsTrue(foreground.VisibleWhenFailing); - Assert.IsTrue(foreground.VisibleWhenPassing); - Assert.AreEqual("Foreground", foreground.Name); + ClassicAssert.NotNull(foreground); + ClassicAssert.AreEqual(151, foreground.Elements.Count); + ClassicAssert.True(foreground.VisibleWhenFailing); + ClassicAssert.True(foreground.VisibleWhenPassing); + ClassicAssert.AreEqual("Foreground", foreground.Name); StoryboardLayer overlay = storyboard.Layers.Single(l => l.Depth == int.MinValue); - Assert.IsNotNull(overlay); - Assert.IsEmpty(overlay.Elements); - Assert.IsTrue(overlay.VisibleWhenFailing); - Assert.IsTrue(overlay.VisibleWhenPassing); - Assert.AreEqual("Overlay", overlay.Name); + ClassicAssert.NotNull(overlay); + ClassicAssert.IsEmpty(overlay.Elements); + ClassicAssert.True(overlay.VisibleWhenFailing); + ClassicAssert.True(overlay.VisibleWhenPassing); + ClassicAssert.AreEqual("Overlay", overlay.Name); int spriteCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardSprite)); int animationCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardAnimation)); int sampleCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardSampleInfo)); - Assert.AreEqual(15, spriteCount); - Assert.AreEqual(1, animationCount); - Assert.AreEqual(0, sampleCount); - Assert.AreEqual(background.Elements.Count, spriteCount + animationCount + sampleCount); + ClassicAssert.AreEqual(15, spriteCount); + ClassicAssert.AreEqual(1, animationCount); + ClassicAssert.AreEqual(0, sampleCount); + ClassicAssert.AreEqual(background.Elements.Count, spriteCount + animationCount + sampleCount); var sprite = background.Elements.ElementAt(0) as StoryboardSprite; - Assert.NotNull(sprite); - Assert.IsTrue(sprite!.HasCommands); - Assert.AreEqual(new Vector2(320, 240), sprite.InitialPosition); - Assert.IsTrue(sprite.IsDrawable); - Assert.AreEqual(Anchor.Centre, sprite.Origin); - Assert.AreEqual("SB/lyric/ja-21.png", sprite.Path); + ClassicAssert.NotNull(sprite); + ClassicAssert.True(sprite!.HasCommands); + ClassicAssert.AreEqual(new Vector2(320, 240), sprite.InitialPosition); + ClassicAssert.True(sprite.IsDrawable); + ClassicAssert.AreEqual(Anchor.Centre, sprite.Origin); + ClassicAssert.AreEqual("SB/lyric/ja-21.png", sprite.Path); var animation = background.Elements.OfType().First(); - Assert.NotNull(animation); - Assert.AreEqual(141175, animation.EndTime); - Assert.AreEqual(10, animation.FrameCount); - Assert.AreEqual(30, animation.FrameDelay); - Assert.IsTrue(animation.HasCommands); - Assert.AreEqual(new Vector2(320, 240), animation.InitialPosition); - Assert.IsTrue(animation.IsDrawable); - Assert.AreEqual(AnimationLoopType.LoopForever, animation.LoopType); - Assert.AreEqual(Anchor.Centre, animation.Origin); - Assert.AreEqual("SB/red jitter/red_0000.jpg", animation.Path); - Assert.AreEqual(78993, animation.StartTime); + ClassicAssert.NotNull(animation); + ClassicAssert.AreEqual(141175, animation.EndTime); + ClassicAssert.AreEqual(10, animation.FrameCount); + ClassicAssert.AreEqual(30, animation.FrameDelay); + ClassicAssert.True(animation.HasCommands); + ClassicAssert.AreEqual(new Vector2(320, 240), animation.InitialPosition); + ClassicAssert.True(animation.IsDrawable); + ClassicAssert.AreEqual(AnimationLoopType.LoopForever, animation.LoopType); + ClassicAssert.AreEqual(Anchor.Centre, animation.Origin); + ClassicAssert.AreEqual("SB/red jitter/red_0000.jpg", animation.Path); + ClassicAssert.AreEqual(78993, animation.StartTime); } } @@ -106,13 +107,13 @@ public void TestLoopWithoutExplicitFadeOut() var storyboard = decoder.Decode(stream); StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); - Assert.AreEqual(1, background.Elements.Count); + ClassicAssert.AreEqual(1, background.Elements.Count); - Assert.AreEqual(2000, background.Elements[0].StartTime); - Assert.AreEqual(2000, (background.Elements[0] as StoryboardAnimation)?.EarliestTransformTime); + ClassicAssert.AreEqual(2000, background.Elements[0].StartTime); + ClassicAssert.AreEqual(2000, (background.Elements[0] as StoryboardAnimation)?.EarliestTransformTime); - Assert.AreEqual(3000, (background.Elements[0] as StoryboardAnimation)?.GetEndTime()); - Assert.AreEqual(12000, (background.Elements[0] as StoryboardAnimation)?.EndTimeForDisplay); + ClassicAssert.AreEqual(3000, (background.Elements[0] as StoryboardAnimation)?.GetEndTime()); + ClassicAssert.AreEqual(12000, (background.Elements[0] as StoryboardAnimation)?.EndTimeForDisplay); } } @@ -127,11 +128,11 @@ public void TestCorrectAnimationStartTime() var storyboard = decoder.Decode(stream); StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); - Assert.AreEqual(1, background.Elements.Count); + ClassicAssert.AreEqual(1, background.Elements.Count); - Assert.AreEqual(2000, background.Elements[0].StartTime); + ClassicAssert.AreEqual(2000, background.Elements[0].StartTime); // This property should be used in DrawableStoryboardAnimation as a starting point for animation playback. - Assert.AreEqual(1000, (background.Elements[0] as StoryboardAnimation)?.EarliestTransformTime); + ClassicAssert.AreEqual(1000, (background.Elements[0] as StoryboardAnimation)?.EarliestTransformTime); } } @@ -146,10 +147,10 @@ public void TestNoopFadeTransformIsIgnoredForLifetime() var storyboard = decoder.Decode(stream); StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); - Assert.AreEqual(2, background.Elements.Count); + ClassicAssert.AreEqual(2, background.Elements.Count); - Assert.AreEqual(1500, background.Elements[0].StartTime); - Assert.AreEqual(1500, background.Elements[1].StartTime); + ClassicAssert.AreEqual(1500, background.Elements[0].StartTime); + ClassicAssert.AreEqual(1500, background.Elements[1].StartTime); } } @@ -164,12 +165,12 @@ public void TestOutOfOrderStartTimes() var storyboard = decoder.Decode(stream); StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); - Assert.AreEqual(2, background.Elements.Count); + ClassicAssert.AreEqual(2, background.Elements.Count); - Assert.AreEqual(1500, background.Elements[0].StartTime); - Assert.AreEqual(1000, background.Elements[1].StartTime); + ClassicAssert.AreEqual(1500, background.Elements[0].StartTime); + ClassicAssert.AreEqual(1000, background.Elements[1].StartTime); - Assert.AreEqual(1000, storyboard.EarliestEventTime); + ClassicAssert.AreEqual(1000, storyboard.EarliestEventTime); } } @@ -184,12 +185,12 @@ public void TestEarliestStartTimeWithLoopAlphas() var storyboard = decoder.Decode(stream); StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); - Assert.AreEqual(2, background.Elements.Count); + ClassicAssert.AreEqual(2, background.Elements.Count); - Assert.AreEqual(1000, background.Elements[0].StartTime); - Assert.AreEqual(1000, background.Elements[1].StartTime); + ClassicAssert.AreEqual(1000, background.Elements[0].StartTime); + ClassicAssert.AreEqual(1000, background.Elements[1].StartTime); - Assert.AreEqual(1000, storyboard.EarliestEventTime); + ClassicAssert.AreEqual(1000, storyboard.EarliestEventTime); } } @@ -204,7 +205,7 @@ public void TestDecodeVariableWithSuffix() var storyboard = decoder.Decode(stream); StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); - Assert.AreEqual(3456, ((StoryboardSprite)background.Elements.Single()).InitialPosition.X); + ClassicAssert.AreEqual(3456, ((StoryboardSprite)background.Elements.Single()).InitialPosition.X); } } @@ -221,7 +222,7 @@ public void TestDecodeVideoWithLowercaseExtension() StoryboardLayer video = storyboard.Layers.Single(l => l.Name == "Video"); Assert.That(video.Elements.Count, Is.EqualTo(1)); - Assert.AreEqual("Video.avi", ((StoryboardVideo)video.Elements[0]).Path); + ClassicAssert.AreEqual("Video.avi", ((StoryboardVideo)video.Elements[0]).Path); } } @@ -238,7 +239,7 @@ public void TestDecodeVideoWithUppercaseExtension() StoryboardLayer video = storyboard.Layers.Single(l => l.Name == "Video"); Assert.That(video.Elements.Count, Is.EqualTo(1)); - Assert.AreEqual("Video.AVI", ((StoryboardVideo)video.Elements[0]).Path); + ClassicAssert.AreEqual("Video.AVI", ((StoryboardVideo)video.Elements[0]).Path); } } @@ -268,12 +269,12 @@ public void TestDecodeOutOfRangeLoopAnimationType() var storyboard = decoder.Decode(stream); StoryboardLayer foreground = storyboard.Layers.Single(l => l.Depth == 0); - Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[0]).LoopType); - Assert.AreEqual(AnimationLoopType.LoopOnce, ((StoryboardAnimation)foreground.Elements[1]).LoopType); - Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[2]).LoopType); - Assert.AreEqual(AnimationLoopType.LoopOnce, ((StoryboardAnimation)foreground.Elements[3]).LoopType); - Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[4]).LoopType); - Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[5]).LoopType); + ClassicAssert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[0]).LoopType); + ClassicAssert.AreEqual(AnimationLoopType.LoopOnce, ((StoryboardAnimation)foreground.Elements[1]).LoopType); + ClassicAssert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[2]).LoopType); + ClassicAssert.AreEqual(AnimationLoopType.LoopOnce, ((StoryboardAnimation)foreground.Elements[3]).LoopType); + ClassicAssert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[4]).LoopType); + ClassicAssert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[5]).LoopType); } } diff --git a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs index c20cf7befd10..c0c330dba5b9 100644 --- a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs @@ -5,8 +5,8 @@ using System.IO; using System.Linq; -using DeepEqual.Syntax; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; @@ -15,7 +15,6 @@ using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; -using osu.Game.Rulesets.Scoring; using osu.Game.Tests.Resources; using osuTK; @@ -33,17 +32,17 @@ public void TestDecodeMetadata() { var beatmap = decodeAsJson(normal); var meta = beatmap.BeatmapInfo.Metadata; - Assert.AreEqual(241526, beatmap.BeatmapInfo.BeatmapSet?.OnlineID); - Assert.AreEqual("Soleily", meta.Artist); - Assert.AreEqual("Soleily", meta.ArtistUnicode); - Assert.AreEqual("03. Renatus - Soleily 192kbps.mp3", meta.AudioFile); - Assert.AreEqual("Gamu", meta.Author.Username); - Assert.AreEqual("machinetop_background.jpg", meta.BackgroundFile); - Assert.AreEqual(164471, meta.PreviewTime); - Assert.AreEqual(string.Empty, meta.Source); - Assert.AreEqual("MBC7 Unisphere 地球ヤバイEP Chikyu Yabai", meta.Tags); - Assert.AreEqual("Renatus", meta.Title); - Assert.AreEqual("Renatus", meta.TitleUnicode); + ClassicAssert.AreEqual(241526, beatmap.BeatmapInfo.BeatmapSet?.OnlineID); + ClassicAssert.AreEqual("Soleily", meta.Artist); + ClassicAssert.AreEqual("Soleily", meta.ArtistUnicode); + ClassicAssert.AreEqual("03. Renatus - Soleily 192kbps.mp3", meta.AudioFile); + ClassicAssert.AreEqual("Gamu", meta.Author.Username); + ClassicAssert.AreEqual("machinetop_background.jpg", meta.BackgroundFile); + ClassicAssert.AreEqual(164471, meta.PreviewTime); + ClassicAssert.AreEqual(string.Empty, meta.Source); + ClassicAssert.AreEqual("MBC7 Unisphere 地球ヤバイEP Chikyu Yabai", meta.Tags); + ClassicAssert.AreEqual("Renatus", meta.Title); + ClassicAssert.AreEqual("Renatus", meta.TitleUnicode); } [Test] @@ -51,14 +50,14 @@ public void TestDecodeGeneral() { var beatmap = decodeAsJson(normal); var beatmapInfo = beatmap.BeatmapInfo; - Assert.AreEqual(0, beatmap.AudioLeadIn); - Assert.AreEqual(0.7f, beatmap.StackLeniency); - Assert.AreEqual(false, beatmap.SpecialStyle); - Assert.IsTrue(beatmapInfo.Ruleset.OnlineID == 0); - Assert.AreEqual(false, beatmap.LetterboxInBreaks); - Assert.AreEqual(false, beatmap.WidescreenStoryboard); - Assert.AreEqual(CountdownType.None, beatmap.Countdown); - Assert.AreEqual(0, beatmap.CountdownOffset); + ClassicAssert.AreEqual(0, beatmap.AudioLeadIn); + ClassicAssert.AreEqual(0.7f, beatmap.StackLeniency); + ClassicAssert.AreEqual(false, beatmap.SpecialStyle); + ClassicAssert.True(beatmapInfo.Ruleset.OnlineID == 0); + ClassicAssert.AreEqual(false, beatmap.LetterboxInBreaks); + ClassicAssert.AreEqual(false, beatmap.WidescreenStoryboard); + ClassicAssert.AreEqual(CountdownType.None, beatmap.Countdown); + ClassicAssert.AreEqual(0, beatmap.CountdownOffset); } [Test] @@ -73,13 +72,13 @@ public void TestDecodeEditor() 95901, 106450, 116999, 119637, 130186, 140735, 151285, 161834, 164471, 175020, 185570, 196119, 206669, 209306 }; - Assert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length); + ClassicAssert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length); for (int i = 0; i < expectedBookmarks.Length; i++) - Assert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]); - Assert.AreEqual(1.8, beatmap.DistanceSpacing); - Assert.AreEqual(4, beatmapInfo.BeatDivisor); - Assert.AreEqual(4, beatmap.GridSize); - Assert.AreEqual(2, beatmap.TimelineZoom); + ClassicAssert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]); + ClassicAssert.AreEqual(1.8, beatmap.DistanceSpacing); + ClassicAssert.AreEqual(4, beatmapInfo.BeatDivisor); + ClassicAssert.AreEqual(4, beatmap.GridSize); + ClassicAssert.AreEqual(2, beatmap.TimelineZoom); } [Test] @@ -87,12 +86,12 @@ public void TestDecodeDifficulty() { var beatmap = decodeAsJson(normal); var difficulty = beatmap.Difficulty; - Assert.AreEqual(6.5f, difficulty.DrainRate); - Assert.AreEqual(4, difficulty.CircleSize); - Assert.AreEqual(8, difficulty.OverallDifficulty); - Assert.AreEqual(9, difficulty.ApproachRate); - Assert.AreEqual(1.8, difficulty.SliderMultiplier); - Assert.AreEqual(2, difficulty.SliderTickRate); + ClassicAssert.AreEqual(6.5f, difficulty.DrainRate); + ClassicAssert.AreEqual(4, difficulty.CircleSize); + ClassicAssert.AreEqual(8, difficulty.OverallDifficulty); + ClassicAssert.AreEqual(9, difficulty.ApproachRate); + ClassicAssert.AreEqual(1.8, difficulty.SliderMultiplier); + ClassicAssert.AreEqual(2, difficulty.SliderTickRate); } [Test] @@ -112,19 +111,19 @@ public void TestDecodePostConverted() var curveData = beatmap.HitObjects[0] as IHasPathWithRepeats; var positionData = beatmap.HitObjects[0] as IHasPosition; - Assert.IsNotNull(positionData); - Assert.IsNotNull(curveData); - Assert.AreEqual(90, curveData.Path.Distance); - Assert.AreEqual(new Vector2(192, 168), positionData.Position); - Assert.AreEqual(956, beatmap.HitObjects[0].StartTime); - Assert.IsTrue(beatmap.HitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL)); + Assert.That(positionData, Is.Not.Null); + Assert.That(curveData, Is.Not.Null); + ClassicAssert.AreEqual(90, curveData.Path.Distance); + ClassicAssert.AreEqual(new Vector2(192, 168), positionData.Position); + ClassicAssert.AreEqual(956, beatmap.HitObjects[0].StartTime); + ClassicAssert.True(beatmap.HitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL)); positionData = beatmap.HitObjects[1] as IHasPosition; - Assert.IsNotNull(positionData); - Assert.AreEqual(new Vector2(304, 56), positionData.Position); - Assert.AreEqual(1285, beatmap.HitObjects[1].StartTime); - Assert.IsTrue(beatmap.HitObjects[1].Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)); + Assert.That(positionData, Is.Not.Null); + ClassicAssert.AreEqual(new Vector2(304, 56), positionData.Position); + ClassicAssert.AreEqual(1285, beatmap.HitObjects[1].StartTime); + ClassicAssert.True(beatmap.HitObjects[1].Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)); } [Test] @@ -135,35 +134,35 @@ public void TestDecodeHitObjects() var curveData = beatmap.HitObjects[0] as IHasPathWithRepeats; var positionData = beatmap.HitObjects[0] as IHasPosition; - Assert.IsNotNull(positionData); - Assert.IsNotNull(curveData); - Assert.AreEqual(90, curveData.Path.Distance); - Assert.AreEqual(new Vector2(192, 168), positionData.Position); - Assert.AreEqual(956, beatmap.HitObjects[0].StartTime); - Assert.IsTrue(beatmap.HitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL)); + Assert.That(positionData, Is.Not.Null); + Assert.That(curveData, Is.Not.Null); + ClassicAssert.AreEqual(90, curveData.Path.Distance); + ClassicAssert.AreEqual(new Vector2(192, 168), positionData.Position); + ClassicAssert.AreEqual(956, beatmap.HitObjects[0].StartTime); + ClassicAssert.True(beatmap.HitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL)); positionData = beatmap.HitObjects[1] as IHasPosition; - Assert.IsNotNull(positionData); - Assert.AreEqual(new Vector2(304, 56), positionData.Position); - Assert.AreEqual(1285, beatmap.HitObjects[1].StartTime); - Assert.IsTrue(beatmap.HitObjects[1].Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)); + Assert.That(positionData, Is.Not.Null); + ClassicAssert.AreEqual(new Vector2(304, 56), positionData.Position); + ClassicAssert.AreEqual(1285, beatmap.HitObjects[1].StartTime); + ClassicAssert.True(beatmap.HitObjects[1].Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)); } - [TestCase(normal)] - [TestCase(marathon)] - [Ignore("temporarily disabled pending DeepEqual fix (https://github.com/jamesfoster/DeepEqual/pull/35)")] - // Currently fails: - // [TestCase(with_sb)] - public void TestParity(string beatmap) - { - var legacy = decode(beatmap, out Beatmap json); - json.WithDeepEqual(legacy) - .IgnoreProperty(r => r.DeclaringType == typeof(HitWindows) - // Todo: CustomSampleBank shouldn't exist going forward, we need a conversion mechanism - || r.Name == nameof(LegacyDecoder.LegacySampleControlPoint.CustomSampleBank)) - .Assert(); - } + // [TestCase(normal)] + // [TestCase(marathon)] + // [Ignore("temporarily disabled pending DeepEqual fix (https://github.com/jamesfoster/DeepEqual/pull/35)")] + // // Currently fails: + // // [TestCase(with_sb)] + // public void TestParity(string beatmap) + // { + // var legacy = decode(beatmap, out Beatmap json); + // json.WithDeepEqual(legacy) + // .IgnoreProperty(r => r.DeclaringType == typeof(HitWindows) + // // Todo: CustomSampleBank shouldn't exist going forward, we need a conversion mechanism + // || r.Name == nameof(LegacyDecoder.LegacySampleControlPoint.CustomSampleBank)) + // .Assert(); + // } [Test] public void TestGetJsonDecoder() @@ -187,7 +186,7 @@ public void TestGetJsonDecoder() } } - Assert.IsInstanceOf(typeof(JsonBeatmapDecoder), decoder); + ClassicAssert.IsInstanceOf(typeof(JsonBeatmapDecoder), decoder); } /// diff --git a/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs index 339063633ada..8aaa5dd3f342 100644 --- a/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs @@ -6,6 +6,7 @@ using System; using System.Globalization; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps.Formats; namespace osu.Game.Tests.Beatmaps.Formats @@ -33,9 +34,9 @@ public void TestNaNHandling() [TestCase(-10, 10)] public void TestValidRanges(double input, double limit = Parsing.MAX_PARSE_VALUE) { - Assert.AreEqual(Parsing.ParseInt((input).ToString(CultureInfo.InvariantCulture), (int)limit), (int)input); - Assert.AreEqual(Parsing.ParseFloat((input).ToString(CultureInfo.InvariantCulture), (float)limit), (float)input); - Assert.AreEqual(Parsing.ParseDouble((input).ToString(CultureInfo.InvariantCulture), limit), input); + ClassicAssert.AreEqual(Parsing.ParseInt((input).ToString(CultureInfo.InvariantCulture), (int)limit), (int)input); + ClassicAssert.AreEqual(Parsing.ParseFloat((input).ToString(CultureInfo.InvariantCulture), (float)limit), (float)input); + ClassicAssert.AreEqual(Parsing.ParseDouble((input).ToString(CultureInfo.InvariantCulture), limit), input); } [TestCase(double.PositiveInfinity)] diff --git a/osu.Game.Tests/Beatmaps/IO/BeatmapImportHelper.cs b/osu.Game.Tests/Beatmaps/IO/BeatmapImportHelper.cs index 055832d7536b..6e3025e99f69 100644 --- a/osu.Game.Tests/Beatmaps/IO/BeatmapImportHelper.cs +++ b/osu.Game.Tests/Beatmaps/IO/BeatmapImportHelper.cs @@ -8,7 +8,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; -using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Allocation; using osu.Game.Beatmaps; using osu.Game.Database; @@ -62,16 +62,16 @@ private static void ensureLoaded(OsuGameBase osu, int timeout = 60000) // TODO: add back some extra checks outside of the realm ones? // var set = queryBeatmapSets().First(); // foreach (BeatmapInfo b in set.Beatmaps) - // Assert.IsTrue(set.Beatmaps.Any(c => c.OnlineID == b.OnlineID)); - // Assert.IsTrue(set.Beatmaps.Count > 0); + // ClassicAssert.True(set.Beatmaps.Any(c => c.OnlineID == b.OnlineID)); + // ClassicAssert.True(set.Beatmaps.Count > 0); // var beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 0))?.Beatmap; - // Assert.IsTrue(beatmap?.HitObjects.Any() == true); + // ClassicAssert.True(beatmap?.HitObjects.Any() == true); // beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 1))?.Beatmap; - // Assert.IsTrue(beatmap?.HitObjects.Any() == true); + // ClassicAssert.True(beatmap?.HitObjects.Any() == true); // beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 2))?.Beatmap; - // Assert.IsTrue(beatmap?.HitObjects.Any() == true); + // ClassicAssert.True(beatmap?.HitObjects.Any() == true); // beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 3))?.Beatmap; - // Assert.IsTrue(beatmap?.HitObjects.Any() == true); + // ClassicAssert.True(beatmap?.HitObjects.Any() == true); } private static void waitForOrAssert(Func result, string failureMessage, int timeout = 60000) @@ -81,7 +81,7 @@ private static void waitForOrAssert(Func result, string failureMessage, in while (!result()) Thread.Sleep(200); }); - Assert.IsTrue(task.Wait(timeout), failureMessage); + ClassicAssert.True(task.Wait(timeout), failureMessage); } } } diff --git a/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs b/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs index 5e37f01c8122..50c12a3a2495 100644 --- a/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs @@ -5,6 +5,7 @@ using System.IO; using System.Text; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.IO; namespace osu.Game.Tests.Beatmaps.IO @@ -20,10 +21,10 @@ public void TestReadLineByLine() using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) using (var bufferedReader = new LineBufferedReader(stream)) { - Assert.AreEqual("line 1", bufferedReader.ReadLine()); - Assert.AreEqual("line 2", bufferedReader.ReadLine()); - Assert.AreEqual("line 3", bufferedReader.ReadLine()); - Assert.IsNull(bufferedReader.ReadLine()); + ClassicAssert.AreEqual("line 1", bufferedReader.ReadLine()); + ClassicAssert.AreEqual("line 2", bufferedReader.ReadLine()); + ClassicAssert.AreEqual("line 3", bufferedReader.ReadLine()); + ClassicAssert.Null(bufferedReader.ReadLine()); } } @@ -35,11 +36,11 @@ public void TestPeekLineOnce() using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) using (var bufferedReader = new LineBufferedReader(stream)) { - Assert.AreEqual("line 1", bufferedReader.ReadLine()); - Assert.AreEqual("peek this", bufferedReader.PeekLine()); - Assert.AreEqual("peek this", bufferedReader.ReadLine()); - Assert.AreEqual("line 3", bufferedReader.ReadLine()); - Assert.IsNull(bufferedReader.ReadLine()); + ClassicAssert.AreEqual("line 1", bufferedReader.ReadLine()); + ClassicAssert.AreEqual("peek this", bufferedReader.PeekLine()); + ClassicAssert.AreEqual("peek this", bufferedReader.ReadLine()); + ClassicAssert.AreEqual("line 3", bufferedReader.ReadLine()); + ClassicAssert.Null(bufferedReader.ReadLine()); } } @@ -51,14 +52,14 @@ public void TestPeekLineMultipleTimes() using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) using (var bufferedReader = new LineBufferedReader(stream)) { - Assert.AreEqual("peek this once", bufferedReader.PeekLine()); - Assert.AreEqual("peek this once", bufferedReader.ReadLine()); - Assert.AreEqual("line 2", bufferedReader.ReadLine()); - Assert.AreEqual("peek this a lot", bufferedReader.PeekLine()); - Assert.AreEqual("peek this a lot", bufferedReader.PeekLine()); - Assert.AreEqual("peek this a lot", bufferedReader.PeekLine()); - Assert.AreEqual("peek this a lot", bufferedReader.ReadLine()); - Assert.IsNull(bufferedReader.ReadLine()); + ClassicAssert.AreEqual("peek this once", bufferedReader.PeekLine()); + ClassicAssert.AreEqual("peek this once", bufferedReader.ReadLine()); + ClassicAssert.AreEqual("line 2", bufferedReader.ReadLine()); + ClassicAssert.AreEqual("peek this a lot", bufferedReader.PeekLine()); + ClassicAssert.AreEqual("peek this a lot", bufferedReader.PeekLine()); + ClassicAssert.AreEqual("peek this a lot", bufferedReader.PeekLine()); + ClassicAssert.AreEqual("peek this a lot", bufferedReader.ReadLine()); + ClassicAssert.Null(bufferedReader.ReadLine()); } } @@ -70,11 +71,11 @@ public void TestPeekLineAtEndOfStream() using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) using (var bufferedReader = new LineBufferedReader(stream)) { - Assert.AreEqual("first line", bufferedReader.ReadLine()); - Assert.AreEqual("second line", bufferedReader.ReadLine()); - Assert.IsNull(bufferedReader.PeekLine()); - Assert.IsNull(bufferedReader.ReadLine()); - Assert.IsNull(bufferedReader.PeekLine()); + ClassicAssert.AreEqual("first line", bufferedReader.ReadLine()); + ClassicAssert.AreEqual("second line", bufferedReader.ReadLine()); + ClassicAssert.Null(bufferedReader.PeekLine()); + ClassicAssert.Null(bufferedReader.ReadLine()); + ClassicAssert.Null(bufferedReader.PeekLine()); } } @@ -84,10 +85,10 @@ public void TestPeekReadLineOnEmptyStream() using (var stream = new MemoryStream()) using (var bufferedReader = new LineBufferedReader(stream)) { - Assert.IsNull(bufferedReader.PeekLine()); - Assert.IsNull(bufferedReader.ReadLine()); - Assert.IsNull(bufferedReader.ReadLine()); - Assert.IsNull(bufferedReader.PeekLine()); + ClassicAssert.Null(bufferedReader.PeekLine()); + ClassicAssert.Null(bufferedReader.ReadLine()); + ClassicAssert.Null(bufferedReader.ReadLine()); + ClassicAssert.Null(bufferedReader.PeekLine()); } } @@ -99,7 +100,7 @@ public void TestReadToEndNoPeeks() using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) using (var bufferedReader = new LineBufferedReader(stream)) { - Assert.AreEqual(contents, bufferedReader.ReadToEnd()); + ClassicAssert.AreEqual(contents, bufferedReader.ReadToEnd()); } } @@ -111,14 +112,14 @@ public void TestReadToEndAfterReadsAndPeeks() using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) using (var bufferedReader = new LineBufferedReader(stream)) { - Assert.AreEqual("this line is gone", bufferedReader.ReadLine()); - Assert.AreEqual("this one shouldn't be", bufferedReader.PeekLine()); + ClassicAssert.AreEqual("this line is gone", bufferedReader.ReadLine()); + ClassicAssert.AreEqual("this one shouldn't be", bufferedReader.PeekLine()); string[] endingLines = bufferedReader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - Assert.AreEqual(3, endingLines.Length); - Assert.AreEqual("this one shouldn't be", endingLines[0]); - Assert.AreEqual("these ones", endingLines[1]); - Assert.AreEqual("definitely not", endingLines[2]); + ClassicAssert.AreEqual(3, endingLines.Length); + ClassicAssert.AreEqual("this one shouldn't be", endingLines[0]); + ClassicAssert.AreEqual("these ones", endingLines[1]); + ClassicAssert.AreEqual("definitely not", endingLines[2]); } } } diff --git a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs index 810ea5dbd078..fbc0975cfb05 100644 --- a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Tests.Resources; using osu.Game.Beatmaps.Formats; @@ -38,7 +39,7 @@ public void TestReadBeatmaps() }; string[] maps = reader.Filenames.ToArray(); foreach (string map in expected) - Assert.Contains(map, maps); + ClassicAssert.Contains(map, maps); } } @@ -56,17 +57,17 @@ public void TestReadMetadata() var meta = beatmap.Metadata; - Assert.AreEqual(241526, beatmap.BeatmapInfo.BeatmapSet?.OnlineID); - Assert.AreEqual("Soleily", meta.Artist); - Assert.AreEqual("Soleily", meta.ArtistUnicode); - Assert.AreEqual("03. Renatus - Soleily 192kbps.mp3", meta.AudioFile); - Assert.AreEqual("Deif", meta.Author.Username); - Assert.AreEqual("machinetop_background.jpg", meta.BackgroundFile); - Assert.AreEqual(164471, meta.PreviewTime); - Assert.AreEqual(string.Empty, meta.Source); - Assert.AreEqual("MBC7 Unisphere 地球ヤバイEP Chikyu Yabai", meta.Tags); - Assert.AreEqual("Renatus", meta.Title); - Assert.AreEqual("Renatus", meta.TitleUnicode); + ClassicAssert.AreEqual(241526, beatmap.BeatmapInfo.BeatmapSet?.OnlineID); + ClassicAssert.AreEqual("Soleily", meta.Artist); + ClassicAssert.AreEqual("Soleily", meta.ArtistUnicode); + ClassicAssert.AreEqual("03. Renatus - Soleily 192kbps.mp3", meta.AudioFile); + ClassicAssert.AreEqual("Deif", meta.Author.Username); + ClassicAssert.AreEqual("machinetop_background.jpg", meta.BackgroundFile); + ClassicAssert.AreEqual(164471, meta.PreviewTime); + ClassicAssert.AreEqual(string.Empty, meta.Source); + ClassicAssert.AreEqual("MBC7 Unisphere 地球ヤバイEP Chikyu Yabai", meta.Tags); + ClassicAssert.AreEqual("Renatus", meta.Title); + ClassicAssert.AreEqual("Renatus", meta.TitleUnicode); } } @@ -79,7 +80,7 @@ public void TestReadFile() using (var stream = new StreamReader(reader.GetStream("Soleily - Renatus (Deif) [Platter].osu"))) { - Assert.AreEqual("osu file format v13", stream.ReadLine()?.Trim()); + ClassicAssert.AreEqual("osu file format v13", stream.ReadLine()?.Trim()); } } } diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs index 7a05a3da5ce0..fc986d0a6067 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -4,10 +4,12 @@ #nullable disable using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -15,6 +17,7 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Visual; @@ -34,6 +37,12 @@ public partial class TestSceneBeatmapDifficultyCache : OsuTestScene private IBindable starDifficultyBindable; + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + [Resolved] + private BeatmapDifficultyCache actualDifficultyCache { get; set; } + [BackgroundDependencyLoader] private void load(OsuGameBase osu) { @@ -55,6 +64,36 @@ public void SetUpSteps() AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value.Stars == BASE_STARS); } + [Test] + public void TestInvalidationFlow() + { + BeatmapInfo postEditBeatmapInfo = null; + BeatmapInfo preEditBeatmapInfo = null; + + IBindable bindableDifficulty = null; + + AddStep("get bindable stars", () => + { + preEditBeatmapInfo = importedSet.Beatmaps.First(); + bindableDifficulty = actualDifficultyCache.GetBindableDifficulty(preEditBeatmapInfo); + }); + + AddUntilStep("wait for stars retrieved", () => bindableDifficulty.Value.Stars, () => Is.GreaterThan(0)); + + AddStep("remove all hitobjects", () => + { + var working = beatmapManager.GetWorkingBeatmap(preEditBeatmapInfo); + + ((IList)working.Beatmap.HitObjects).Clear(); + + beatmapManager.Save(working.BeatmapInfo, working.Beatmap); + postEditBeatmapInfo = working.BeatmapInfo; + }); + + AddAssert("stars is now zero", () => actualDifficultyCache.GetDifficultyAsync(postEditBeatmapInfo).GetResultSafely()!.Value.Stars, () => Is.Zero); + AddUntilStep("bindable stars is now zero", () => bindableDifficulty.Value.Stars, () => Is.Zero); + } + [Test] public void TestStarDifficultyChangesOnModSettings() { @@ -76,6 +115,30 @@ public void TestStarDifficultyChangesOnModSettings() AddUntilStep($"star difficulty -> {BASE_STARS + 1.75}", () => starDifficultyBindable.Value.Stars == BASE_STARS + 1.75); } + [Test] + public void TestStarDifficultyChangesOnModSettingsCorrectlyTrackAcrossReferenceChanges() + { + OsuModDoubleTime dt = null; + + AddStep("set computation function", () => difficultyCache.ComputeDifficulty = lookup => + { + var modRateAdjust = (ModRateAdjust)lookup.OrderedMods.SingleOrDefault(mod => mod is ModRateAdjust); + return new StarDifficulty(BASE_STARS + modRateAdjust?.SpeedChange.Value ?? 0, 0); + }); + + AddStep("change selected mod to DT", () => SelectedMods.Value = new[] { dt = new OsuModDoubleTime { SpeedChange = { Value = 1.5 } } }); + AddUntilStep($"star difficulty -> {BASE_STARS + 1.5}", () => starDifficultyBindable.Value.Stars == BASE_STARS + 1.5); + + AddStep("change DT speed to 1.25", () => dt.SpeedChange.Value = 1.25); + AddUntilStep($"star difficulty -> {BASE_STARS + 1.25}", () => starDifficultyBindable.Value.Stars == BASE_STARS + 1.25); + + AddStep("reconstruct DT mod with same settings", () => SelectedMods.Value = new[] { dt = (OsuModDoubleTime)dt.DeepClone() }); + AddUntilStep($"star difficulty -> {BASE_STARS + 1.25}", () => starDifficultyBindable.Value.Stars == BASE_STARS + 1.25); + + AddStep("change DT speed to 1.25", () => dt.SpeedChange.Value = 2); + AddUntilStep($"star difficulty -> {BASE_STARS + 2}", () => starDifficultyBindable.Value.Stars == BASE_STARS + 2); + } + [Test] public void TestStarDifficultyAdjustHashCodeConflict() { @@ -122,8 +185,10 @@ public void TestKeyEqualsWithDifferentModOrder() [Test] public void TestKeyDoesntEqualWithDifferentModSettings() { - var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } }); - var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.9 } } }); + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, + new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, + new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.9 } } }); Assert.That(key1, Is.Not.EqualTo(key2)); Assert.That(key1.GetHashCode(), Is.Not.EqualTo(key2.GetHashCode())); @@ -132,8 +197,10 @@ public void TestKeyDoesntEqualWithDifferentModSettings() [Test] public void TestKeyEqualWithMatchingModSettings() { - var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); - var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, + new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, + new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); Assert.That(key1, Is.EqualTo(key2)); Assert.That(key1.GetHashCode(), Is.EqualTo(key2.GetHashCode())); @@ -159,7 +226,7 @@ public void TestDifficultyRatingMapping(double starRating, DifficultyRating expe { var actualBracket = StarDifficulty.GetDifficultyRating(starRating); - Assert.AreEqual(expectedBracket, actualBracket); + ClassicAssert.AreEqual(expectedBracket, actualBracket); } private partial class TestBeatmapDifficultyCache : BeatmapDifficultyCache diff --git a/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs b/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs index bb24560a44c5..73c15e0bcda6 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs @@ -247,7 +247,7 @@ public void TestMultipleHitObjectUpdate() AddStep("change all start times", () => { - editorBeatmap.HitObjectUpdated += h => updatedObjects.Add(h); + editorBeatmap.HitObjectUpdated += updatedObjects.Add; for (int i = 0; i < 10; i++) allHitObjects[i].StartTime += 10; @@ -282,7 +282,7 @@ public void TestDebouncedUpdate() AddStep("change start time twice", () => { - editorBeatmap.HitObjectUpdated += h => updatedObjects.Add(h); + editorBeatmap.HitObjectUpdated += updatedObjects.Add; editorBeatmap.HitObjects[0].StartTime = 10; editorBeatmap.HitObjects[0].StartTime = 20; diff --git a/osu.Game.Tests/Beatmaps/WorkingBeatmapTest.cs b/osu.Game.Tests/Beatmaps/WorkingBeatmapTest.cs index 3c26f8e39a76..1f50574619f0 100644 --- a/osu.Game.Tests/Beatmaps/WorkingBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/WorkingBeatmapTest.cs @@ -10,6 +10,7 @@ using JetBrains.Annotations; using Moq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -29,7 +30,7 @@ public void TestGetPlayableSuccess() working.ResetEvent.Set(); - Assert.NotNull(working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); + ClassicAssert.NotNull(working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); } [Test] @@ -48,11 +49,11 @@ public void TestGetPlayableCancellationToken() loadCompleted.Set(); }, TaskCreationOptions.LongRunning); - Assert.IsTrue(loadStarted.Wait(10000)); + ClassicAssert.True(loadStarted.Wait(10000)); cts.Cancel(); - Assert.IsTrue(loadCompleted.Wait(10000)); + ClassicAssert.True(loadCompleted.Wait(10000)); working.ResetEvent.Set(); } diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index 1baa737a9c99..bf04ec646669 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -4,6 +4,7 @@ #nullable disable using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Online.Chat; namespace osu.Game.Tests.Chat @@ -31,8 +32,8 @@ public void TestUnsupportedProtocolLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a gopher://really-old-protocol we don't support." }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(0, result.Links.Count); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(0, result.Links.Count); } [Test] @@ -40,8 +41,8 @@ public void TestFakeProtocolLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a osunotarealprotocol://completely-made-up-protocol we don't support." }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(0, result.Links.Count); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(0, result.Links.Count); } [Test] @@ -49,9 +50,9 @@ public void TestSupportedProtocolLinkParsing() { Message result = MessageFormatter.FormatMessage(new Message { Content = "forgotspacehttps://dev.ppy.sh joinmyosump://12345 jointheosu://chan/#english" }); - Assert.AreEqual("https://dev.ppy.sh", result.Links[0].Url); - Assert.AreEqual("osump://12345", result.Links[1].Url); - Assert.AreEqual("osu://chan/#english", result.Links[2].Url); + ClassicAssert.AreEqual("https://dev.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual("osump://12345", result.Links[1].Url); + ClassicAssert.AreEqual("osu://chan/#english", result.Links[2].Url); } [Test] @@ -59,11 +60,11 @@ public void TestBareLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a http://www.basic-link.com/?test=test." }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("http://www.basic-link.com/?test=test", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(36, result.Links[0].Length); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("http://www.basic-link.com/?test=test", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(36, result.Links[0].Length); } [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123#osu/456")] @@ -79,12 +80,12 @@ public void TestBeatmapLinks(LinkAction expectedAction, string expectedArg, stri { Message result = MessageFormatter.FormatMessage(new Message { Content = link }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual(expectedAction, result.Links[0].Action); - Assert.AreEqual(expectedArg, result.Links[0].Argument); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual(expectedAction, result.Links[0].Action); + ClassicAssert.AreEqual(expectedArg, result.Links[0].Argument); if (expectedAction == LinkAction.External) - Assert.AreEqual(link, result.Links[0].Url); + ClassicAssert.AreEqual(link, result.Links[0].Url); } [Test] @@ -95,20 +96,20 @@ public void TestMultipleComplexLinks() Content = "This is a http://test.io/link#fragment. (see https://twitter.com). Also, This string should not be altered. http://example.com/" }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(3, result.Links.Count); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(3, result.Links.Count); - Assert.AreEqual("http://test.io/link#fragment", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(28, result.Links[0].Length); + ClassicAssert.AreEqual("http://test.io/link#fragment", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(28, result.Links[0].Length); - Assert.AreEqual("https://twitter.com", result.Links[1].Url); - Assert.AreEqual(45, result.Links[1].Index); - Assert.AreEqual(19, result.Links[1].Length); + ClassicAssert.AreEqual("https://twitter.com", result.Links[1].Url); + ClassicAssert.AreEqual(45, result.Links[1].Index); + ClassicAssert.AreEqual(19, result.Links[1].Length); - Assert.AreEqual("http://example.com/", result.Links[2].Url); - Assert.AreEqual(108, result.Links[2].Index); - Assert.AreEqual(19, result.Links[2].Length); + ClassicAssert.AreEqual("http://example.com/", result.Links[2].Url); + ClassicAssert.AreEqual(108, result.Links[2].Index); + ClassicAssert.AreEqual(19, result.Links[2].Length); } [Test] @@ -116,10 +117,10 @@ public void TestAjaxLinks() { Message result = MessageFormatter.FormatMessage(new Message { Content = "https://twitter.com/#!/hashbanglinks" }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(result.Content, result.Links[0].Url); - Assert.AreEqual(0, result.Links[0].Index); - Assert.AreEqual(36, result.Links[0].Length); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(result.Content, result.Links[0].Url); + ClassicAssert.AreEqual(0, result.Links[0].Index); + ClassicAssert.AreEqual(36, result.Links[0].Length); } [Test] @@ -127,10 +128,10 @@ public void TestUnixHomeLinks() { Message result = MessageFormatter.FormatMessage(new Message { Content = "http://www.chiark.greenend.org.uk/~sgtatham/putty/" }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(result.Content, result.Links[0].Url); - Assert.AreEqual(0, result.Links[0].Index); - Assert.AreEqual(50, result.Links[0].Length); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(result.Content, result.Links[0].Url); + ClassicAssert.AreEqual(0, result.Links[0].Index); + ClassicAssert.AreEqual(50, result.Links[0].Length); } [Test] @@ -138,9 +139,9 @@ public void TestInsensitiveLinks() { Message result = MessageFormatter.FormatMessage(new Message { Content = "look: http://puu.sh/7Ggh8xcC6/asf0asd9876.NEF" }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(6, result.Links[0].Index); - Assert.AreEqual(39, result.Links[0].Length); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(6, result.Links[0].Index); + ClassicAssert.AreEqual(39, result.Links[0].Length); } [Test] @@ -148,11 +149,11 @@ public void TestWikiLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [[Wiki Link]]." }); - Assert.AreEqual("This is a Wiki Link.", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://dev.ppy.sh/wiki/Wiki Link", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(9, result.Links[0].Length); + ClassicAssert.AreEqual("This is a Wiki Link.", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://dev.ppy.sh/wiki/Wiki Link", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(9, result.Links[0].Length); } [Test] @@ -160,20 +161,20 @@ public void TestMultiWikiLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [[Wiki Link]] [[Wiki:Link]][[Wiki.Link]]." }); - Assert.AreEqual("This is a Wiki Link Wiki:LinkWiki.Link.", result.DisplayContent); - Assert.AreEqual(3, result.Links.Count); + ClassicAssert.AreEqual("This is a Wiki Link Wiki:LinkWiki.Link.", result.DisplayContent); + ClassicAssert.AreEqual(3, result.Links.Count); - Assert.AreEqual("https://dev.ppy.sh/wiki/Wiki Link", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(9, result.Links[0].Length); + ClassicAssert.AreEqual("https://dev.ppy.sh/wiki/Wiki Link", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(9, result.Links[0].Length); - Assert.AreEqual("https://dev.ppy.sh/wiki/Wiki:Link", result.Links[1].Url); - Assert.AreEqual(20, result.Links[1].Index); - Assert.AreEqual(9, result.Links[1].Length); + ClassicAssert.AreEqual("https://dev.ppy.sh/wiki/Wiki:Link", result.Links[1].Url); + ClassicAssert.AreEqual(20, result.Links[1].Index); + ClassicAssert.AreEqual(9, result.Links[1].Length); - Assert.AreEqual("https://dev.ppy.sh/wiki/Wiki.Link", result.Links[2].Url); - Assert.AreEqual(29, result.Links[2].Index); - Assert.AreEqual(9, result.Links[2].Length); + ClassicAssert.AreEqual("https://dev.ppy.sh/wiki/Wiki.Link", result.Links[2].Url); + ClassicAssert.AreEqual(29, result.Links[2].Index); + ClassicAssert.AreEqual(9, result.Links[2].Length); } [Test] @@ -181,11 +182,11 @@ public void TestOldFormatLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a (simple test)[https://osu.ppy.sh] of links." }); - Assert.AreEqual("This is a simple test of links.", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(11, result.Links[0].Length); + ClassicAssert.AreEqual("This is a simple test of links.", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(11, result.Links[0].Length); } [Test] @@ -193,11 +194,11 @@ public void TestOldFormatLinkWithBalancedBrackets() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a (tricky (one))[https://osu.ppy.sh]!" }); - Assert.AreEqual("This is a tricky (one)!", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(12, result.Links[0].Length); + ClassicAssert.AreEqual("This is a tricky (one)!", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(12, result.Links[0].Length); } [Test] @@ -205,22 +206,22 @@ public void TestOldFormatLinkWithEscapedBrackets() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is (another loose bracket \\))[https://osu.ppy.sh]." }); - Assert.AreEqual("This is another loose bracket ).", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(8, result.Links[0].Index); - Assert.AreEqual(23, result.Links[0].Length); + ClassicAssert.AreEqual("This is another loose bracket ).", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(8, result.Links[0].Index); + ClassicAssert.AreEqual(23, result.Links[0].Length); } [Test] public void TestOldFormatWithBackslashes() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This link (should end with a backslash \\)[https://osu.ppy.sh]." }); - Assert.AreEqual("This link should end with a backslash \\.", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(29, result.Links[0].Length); + ClassicAssert.AreEqual("This link should end with a backslash \\.", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(29, result.Links[0].Length); } [Test] @@ -228,11 +229,11 @@ public void TestOldFormatLinkWithEscapedAndBalancedBrackets() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a (\\)super\\(\\( tricky (one))[https://osu.ppy.sh]!" }); - Assert.AreEqual("This is a )super(( tricky (one)!", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(21, result.Links[0].Length); + ClassicAssert.AreEqual("This is a )super(( tricky (one)!", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(21, result.Links[0].Length); } [Test] @@ -240,11 +241,11 @@ public void TestNewFormatLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh simple test]." }); - Assert.AreEqual("This is a simple test.", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(11, result.Links[0].Length); + ClassicAssert.AreEqual("This is a simple test.", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(11, result.Links[0].Length); } [Test] @@ -252,11 +253,11 @@ public void TestNewFormatLinkWithEscapedBrackets() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh nasty link with escaped brackets: \\] and \\[]" }); - Assert.AreEqual("This is a nasty link with escaped brackets: ] and [", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(41, result.Links[0].Length); + ClassicAssert.AreEqual("This is a nasty link with escaped brackets: ] and [", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(41, result.Links[0].Length); } [Test] @@ -264,11 +265,11 @@ public void TestNewFormatLinkWithBackslashesInside() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh link \\ with \\ backslashes \\]" }); - Assert.AreEqual("This is a link \\ with \\ backslashes \\", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(27, result.Links[0].Length); + ClassicAssert.AreEqual("This is a link \\ with \\ backslashes \\", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(27, result.Links[0].Length); } [Test] @@ -276,11 +277,11 @@ public void TestNewFormatLinkWithEscapedAndBalancedBrackets() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh [link [with \\] too many brackets \\[ ]]]" }); - Assert.AreEqual("This is a [link [with ] too many brackets [ ]]", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(36, result.Links[0].Length); + ClassicAssert.AreEqual("This is a [link [with ] too many brackets [ ]]", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(36, result.Links[0].Length); } [Test] @@ -288,11 +289,11 @@ public void TestMarkdownFormatLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [simple test](https://osu.ppy.sh)." }); - Assert.AreEqual("This is a simple test.", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(11, result.Links[0].Length); + ClassicAssert.AreEqual("This is a simple test.", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(11, result.Links[0].Length); } [Test] @@ -300,11 +301,11 @@ public void TestMarkdownFormatLinkWithBalancedBrackets() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [tricky [one]](https://osu.ppy.sh)!" }); - Assert.AreEqual("This is a tricky [one]!", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(12, result.Links[0].Length); + ClassicAssert.AreEqual("This is a tricky [one]!", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(12, result.Links[0].Length); } [Test] @@ -312,22 +313,22 @@ public void TestMarkdownFormatLinkWithEscapedBrackets() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is [another loose bracket \\]](https://osu.ppy.sh)." }); - Assert.AreEqual("This is another loose bracket ].", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(8, result.Links[0].Index); - Assert.AreEqual(23, result.Links[0].Length); + ClassicAssert.AreEqual("This is another loose bracket ].", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(8, result.Links[0].Index); + ClassicAssert.AreEqual(23, result.Links[0].Length); } [Test] public void TestMarkdownFormatWithBackslashes() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This link [should end with a backslash \\](https://osu.ppy.sh)." }); - Assert.AreEqual("This link should end with a backslash \\.", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(29, result.Links[0].Length); + ClassicAssert.AreEqual("This link should end with a backslash \\.", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(29, result.Links[0].Length); } [Test] @@ -335,11 +336,11 @@ public void TestMarkdownFormatLinkWithEscapedAndBalancedBrackets() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [\\]super\\[\\[ tricky [one]](https://osu.ppy.sh)!" }); - Assert.AreEqual("This is a ]super[[ tricky [one]!", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(21, result.Links[0].Length); + ClassicAssert.AreEqual("This is a ]super[[ tricky [one]!", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(21, result.Links[0].Length); } [Test] @@ -347,11 +348,11 @@ public void TestMarkdownFormatLinkWithInlineTitle() { Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [this link format](https://osu.ppy.sh \"osu!\") before..." }); - Assert.AreEqual("I haven't seen this link format before...", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(15, result.Links[0].Index); - Assert.AreEqual(16, result.Links[0].Length); + ClassicAssert.AreEqual("I haven't seen this link format before...", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(15, result.Links[0].Index); + ClassicAssert.AreEqual(16, result.Links[0].Length); } [Test] @@ -359,11 +360,11 @@ public void TestMarkdownFormatLinkWithInlineTitleAndEscapedQuotes() { Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [this link format](https://osu.ppy.sh \"inner quote \\\" just to confuse \") before..." }); - Assert.AreEqual("I haven't seen this link format before...", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(15, result.Links[0].Index); - Assert.AreEqual(16, result.Links[0].Length); + ClassicAssert.AreEqual("I haven't seen this link format before...", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(15, result.Links[0].Index); + ClassicAssert.AreEqual(16, result.Links[0].Length); } [Test] @@ -371,11 +372,11 @@ public void TestMarkdownFormatLinkWithUrlInTextAndInlineTitle() { Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [https://osu.ppy.sh](https://osu.ppy.sh \"https://osu.ppy.sh\") before..." }); - Assert.AreEqual("I haven't seen https://osu.ppy.sh before...", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(15, result.Links[0].Index); - Assert.AreEqual(18, result.Links[0].Length); + ClassicAssert.AreEqual("I haven't seen https://osu.ppy.sh before...", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(15, result.Links[0].Index); + ClassicAssert.AreEqual(18, result.Links[0].Length); } [Test] @@ -383,11 +384,11 @@ public void TestMarkdownFormatLinkWithUrlAndTextInTitle() { Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [oh no, text here! https://osu.ppy.sh](https://osu.ppy.sh) before..." }); - Assert.AreEqual("I haven't seen oh no, text here! https://osu.ppy.sh before...", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(15, result.Links[0].Index); - Assert.AreEqual(36, result.Links[0].Length); + ClassicAssert.AreEqual("I haven't seen oh no, text here! https://osu.ppy.sh before...", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(15, result.Links[0].Index); + ClassicAssert.AreEqual(36, result.Links[0].Length); } [Test] @@ -395,11 +396,11 @@ public void TestMarkdownFormatLinkWithMisleadingUrlInText() { Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [https://google.com](https://osu.ppy.sh) before..." }); - Assert.AreEqual("I haven't seen https://google.com before...", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(15, result.Links[0].Index); - Assert.AreEqual(18, result.Links[0].Length); + ClassicAssert.AreEqual("I haven't seen https://google.com before...", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(15, result.Links[0].Index); + ClassicAssert.AreEqual(18, result.Links[0].Length); } [Test] @@ -407,11 +408,11 @@ public void TestMarkdownFormatLinkThatContractsIntoLargerLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "super broken https://[osu.ppy](https://reddit.com).sh/" }); - Assert.AreEqual("super broken https://osu.ppy.sh/", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://reddit.com", result.Links[0].Url); - Assert.AreEqual(21, result.Links[0].Index); - Assert.AreEqual(7, result.Links[0].Length); + ClassicAssert.AreEqual("super broken https://osu.ppy.sh/", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://reddit.com", result.Links[0].Url); + ClassicAssert.AreEqual(21, result.Links[0].Index); + ClassicAssert.AreEqual(7, result.Links[0].Length); } [Test] @@ -420,16 +421,16 @@ public void TestMarkdownFormatLinkDirectlyNextToRawLink() // the raw link has a port at the end of it, so that the raw link regex terminates at the port and doesn't consume display text from the formatted one Message result = MessageFormatter.FormatMessage(new Message { Content = "https://localhost:8080[https://osu.ppy.sh](https://osu.ppy.sh) should be two links" }); - Assert.AreEqual("https://localhost:8080https://osu.ppy.sh should be two links", result.DisplayContent); - Assert.AreEqual(2, result.Links.Count); + ClassicAssert.AreEqual("https://localhost:8080https://osu.ppy.sh should be two links", result.DisplayContent); + ClassicAssert.AreEqual(2, result.Links.Count); - Assert.AreEqual("https://localhost:8080", result.Links[0].Url); - Assert.AreEqual(0, result.Links[0].Index); - Assert.AreEqual(22, result.Links[0].Length); + ClassicAssert.AreEqual("https://localhost:8080", result.Links[0].Url); + ClassicAssert.AreEqual(0, result.Links[0].Index); + ClassicAssert.AreEqual(22, result.Links[0].Length); - Assert.AreEqual("https://osu.ppy.sh", result.Links[1].Url); - Assert.AreEqual(22, result.Links[1].Index); - Assert.AreEqual(18, result.Links[1].Length); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[1].Url); + ClassicAssert.AreEqual(22, result.Links[1].Index); + ClassicAssert.AreEqual(18, result.Links[1].Length); } [Test] @@ -437,10 +438,10 @@ public void TestChannelLink() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is an #english and #japanese." }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(2, result.Links.Count); - Assert.AreEqual($"{OsuGameBase.OSU_PROTOCOL}chan/#english", result.Links[0].Url); - Assert.AreEqual($"{OsuGameBase.OSU_PROTOCOL}chan/#japanese", result.Links[1].Url); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(2, result.Links.Count); + ClassicAssert.AreEqual($"{OsuGameBase.OSU_PROTOCOL}chan/#english", result.Links[0].Url); + ClassicAssert.AreEqual($"{OsuGameBase.OSU_PROTOCOL}chan/#japanese", result.Links[1].Url); } [Test] @@ -448,20 +449,20 @@ public void TestOsuProtocol() { Message result = MessageFormatter.FormatMessage(new Message { Content = $"This is a custom protocol {OsuGameBase.OSU_PROTOCOL}chan/#english." }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual($"{OsuGameBase.OSU_PROTOCOL}chan/#english", result.Links[0].Url); - Assert.AreEqual(26, result.Links[0].Index); - Assert.AreEqual(19, result.Links[0].Length); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual($"{OsuGameBase.OSU_PROTOCOL}chan/#english", result.Links[0].Url); + ClassicAssert.AreEqual(26, result.Links[0].Index); + ClassicAssert.AreEqual(19, result.Links[0].Length); result = MessageFormatter.FormatMessage(new Message { Content = $"This is a [custom protocol]({OsuGameBase.OSU_PROTOCOL}chan/#english)." }); - Assert.AreEqual("This is a custom protocol.", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual($"{OsuGameBase.OSU_PROTOCOL}chan/#english", result.Links[0].Url); - Assert.AreEqual("#english", result.Links[0].Argument); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(15, result.Links[0].Length); + ClassicAssert.AreEqual("This is a custom protocol.", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual($"{OsuGameBase.OSU_PROTOCOL}chan/#english", result.Links[0].Url); + ClassicAssert.AreEqual("#english", result.Links[0].Argument); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(15, result.Links[0].Length); } [Test] @@ -469,11 +470,11 @@ public void TestOsuMpProtocol() { Message result = MessageFormatter.FormatMessage(new Message { Content = "Join my multiplayer game osump://12346." }); - Assert.AreEqual(result.Content, result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("osump://12346", result.Links[0].Url); - Assert.AreEqual(25, result.Links[0].Index); - Assert.AreEqual(13, result.Links[0].Length); + ClassicAssert.AreEqual(result.Content, result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("osump://12346", result.Links[0].Url); + ClassicAssert.AreEqual(25, result.Links[0].Index); + ClassicAssert.AreEqual(13, result.Links[0].Length); } [Test] @@ -481,11 +482,11 @@ public void TestRecursiveBreaking() { Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh [[simple test]]]." }); - Assert.AreEqual("This is a [[simple test]].", result.DisplayContent); - Assert.AreEqual(1, result.Links.Count); - Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); - Assert.AreEqual(10, result.Links[0].Index); - Assert.AreEqual(15, result.Links[0].Length); + ClassicAssert.AreEqual("This is a [[simple test]].", result.DisplayContent); + ClassicAssert.AreEqual(1, result.Links.Count); + ClassicAssert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + ClassicAssert.AreEqual(10, result.Links[0].Index); + ClassicAssert.AreEqual(15, result.Links[0].Length); } [Test] @@ -496,44 +497,44 @@ public void TestLinkComplex() Content = "This is a [http://www.simple-test.com simple test] with some [traps] and [[wiki links]]. Don't forget to visit https://osu.ppy.sh (now!)[http://google.com]\uD83D\uDE12" }); - Assert.AreEqual("This is a simple test with some [traps] and wiki links. Don't forget to visit https://osu.ppy.sh now![emoji]", result.DisplayContent); - Assert.AreEqual(4, result.Links.Count); + ClassicAssert.AreEqual("This is a simple test with some [traps] and wiki links. Don't forget to visit https://osu.ppy.sh now![emoji]", result.DisplayContent); + ClassicAssert.AreEqual(4, result.Links.Count); Link f = result.Links.Find(l => l.Url == "https://dev.ppy.sh/wiki/wiki links"); Assert.That(f, Is.Not.Null); - Assert.AreEqual(44, f.Index); - Assert.AreEqual(10, f.Length); + ClassicAssert.AreEqual(44, f.Index); + ClassicAssert.AreEqual(10, f.Length); f = result.Links.Find(l => l.Url == "http://www.simple-test.com"); Assert.That(f, Is.Not.Null); - Assert.AreEqual(10, f.Index); - Assert.AreEqual(11, f.Length); + ClassicAssert.AreEqual(10, f.Index); + ClassicAssert.AreEqual(11, f.Length); f = result.Links.Find(l => l.Url == "http://google.com"); Assert.That(f, Is.Not.Null); - Assert.AreEqual(97, f.Index); - Assert.AreEqual(4, f.Length); + ClassicAssert.AreEqual(97, f.Index); + ClassicAssert.AreEqual(4, f.Length); f = result.Links.Find(l => l.Url == "https://osu.ppy.sh"); Assert.That(f, Is.Not.Null); - Assert.AreEqual(78, f.Index); - Assert.AreEqual(18, f.Length); + ClassicAssert.AreEqual(78, f.Index); + ClassicAssert.AreEqual(18, f.Length); } [Test] public void TestEmoji() { Message result = MessageFormatter.FormatMessage(new Message { Content = "Hello world\uD83D\uDE12<--This is an emoji,There are more emojis among us:\uD83D\uDE10\uD83D\uDE00,\uD83D\uDE20" }); - Assert.AreEqual("Hello world[emoji]<--This is an emoji,There are more emojis among us:[emoji][emoji],[emoji]", result.DisplayContent); - Assert.AreEqual(result.Links.Count, 0); + ClassicAssert.AreEqual("Hello world[emoji]<--This is an emoji,There are more emojis among us:[emoji][emoji],[emoji]", result.DisplayContent); + ClassicAssert.AreEqual(result.Links.Count, 0); } [Test] public void TestEmojiWithSuccessiveParens() { Message result = MessageFormatter.FormatMessage(new Message { Content = "\uD83D\uDE10(let's hope this doesn't accidentally turn into a link)" }); - Assert.AreEqual("[emoji](let's hope this doesn't accidentally turn into a link)", result.DisplayContent); - Assert.AreEqual(result.Links.Count, 0); + ClassicAssert.AreEqual("[emoji](let's hope this doesn't accidentally turn into a link)", result.DisplayContent); + ClassicAssert.AreEqual(result.Links.Count, 0); } [Test] @@ -541,8 +542,8 @@ public void TestAbsoluteExternalLinks() { LinkDetails result = MessageFormatter.GetLinkDetails("https://google.com"); - Assert.AreEqual(LinkAction.External, result.Action); - Assert.AreEqual("https://google.com", result.Argument); + ClassicAssert.AreEqual(LinkAction.External, result.Action); + ClassicAssert.AreEqual("https://google.com", result.Argument); } [Test] @@ -550,8 +551,8 @@ public void TestRelativeExternalLinks() { LinkDetails result = MessageFormatter.GetLinkDetails("/relative"); - Assert.AreEqual(LinkAction.External, result.Action); - Assert.AreEqual("/relative", result.Argument); + ClassicAssert.AreEqual(LinkAction.External, result.Action); + ClassicAssert.AreEqual("/relative", result.Argument); } [TestCase("https://dev.ppy.sh/home/changelog", "")] @@ -560,8 +561,8 @@ public void TestChangelogLinks(string link, string expectedArg) { LinkDetails result = MessageFormatter.GetLinkDetails(link); - Assert.AreEqual(LinkAction.OpenChangelog, result.Action); - Assert.AreEqual(expectedArg, result.Argument); + ClassicAssert.AreEqual(LinkAction.OpenChangelog, result.Action); + ClassicAssert.AreEqual(expectedArg, result.Argument); } } } diff --git a/osu.Game.Tests/Chat/TestSceneChannelManager.cs b/osu.Game.Tests/Chat/TestSceneChannelManager.cs index ef4d4f683aa5..768137b0cff0 100644 --- a/osu.Game.Tests/Chat/TestSceneChannelManager.cs +++ b/osu.Game.Tests/Chat/TestSceneChannelManager.cs @@ -42,6 +42,7 @@ public void SetUpSteps() sentMessages = new List(); silencedUserIds = new List(); + ((DummyAPIAccess)API).LocalUserState.Blocks.Clear(); ((DummyAPIAccess)API).HandleRequest = req => { switch (req) @@ -63,6 +64,10 @@ public void SetUpSteps() silencedUserIds.Clear(); return true; + case GetMessagesRequest getMessages: + getMessages.TriggerSuccess(sentMessages); + return true; + case GetUpdatesRequest updatesRequest: updatesRequest.TriggerSuccess(new GetUpdatesResponse { @@ -161,6 +166,85 @@ public void TestCommandNameCaseInsensitivity() AddUntilStep("/help command received", () => channel.Messages.Last().Content.Contains("Supported commands")); } + [Test] + public void TestBlockedUserMessagesAreDeletedFromInitialMessageBatch() + { + Channel channel = null; + + AddStep("create channel", () => channel = createChannel(1, ChannelType.Public)); + AddStep("post a message from blocked user", () => sentMessages.Add(new Message + { + ChannelId = channel.Id, + Content = "i am blocked", + SenderId = 1234 + })); + AddStep("mark user as blocked", () => ((DummyAPIAccess)API).LocalUserState.Blocks.Add(new APIRelation + { + TargetUser = new APIUser { Username = "blocked", Id = 1234 }, + TargetID = 1234, + })); + + AddStep("join channel and select it", () => + { + channelManager.JoinChannel(channel); + channelManager.CurrentChannel.Value = channel; + }); + AddAssert("channel has no messages", () => channel.Messages, () => Is.Empty); + } + + [Test] + public void TestBlockedUserMessagesAreDeletedImmediatelyOnBlock() + { + Channel channel = null; + + AddStep("create channel", () => channel = createChannel(1, ChannelType.Public)); + + AddStep("join channel and select it", () => + { + channelManager.JoinChannel(channel); + channelManager.CurrentChannel.Value = channel; + }); + AddStep("post a message from blocked user", () => sentMessages.Add(new Message + { + ChannelId = channel.Id, + Content = "i am blocked", + SenderId = 1234 + })); + AddUntilStep("channel has message", () => channel.Messages, () => Is.Not.Empty); + + AddStep("block user", () => ((DummyAPIAccess)API).LocalUserState.Blocks.Add(new APIRelation + { + TargetUser = new APIUser { Username = "blocked", Id = 1234 }, + TargetID = 1234, + })); + AddAssert("channel has no messages", () => channel.Messages, () => Is.Empty); + } + + [Test] + public void TestPrivateChannelsPurgedOnUserChange() + { + var pmChannel = createChannel(1002, ChannelType.PM); + AddStep("join a few private channels", () => + { + channelManager.JoinChannel(createChannel(1001, ChannelType.PM)); + channelManager.JoinChannel(createChannel(1003, ChannelType.Team)); + channelManager.JoinChannel(pmChannel); + }); + AddStep("close a PM channel", () => channelManager.LeaveChannel(pmChannel)); + + AddStep("switch user", () => + { + ((DummyAPIAccess.DummyLocalUserState)API.LocalUserState).User.Value = new APIUser + { + Id = 9009, + Username = "someone_else" + }; + }); + + AddAssert("not joined to private channels of previous user", + () => !channelManager.JoinedChannels.Select(ch => ch.Id).Any(id => id >= 1001 && id <= 1003)); + } + private void handlePostMessageRequest(PostMessageRequest request) { var message = new Message(++currentMessageId) @@ -191,7 +275,7 @@ private void handleMarkChannelAsReadRequest(MarkChannelAsReadRequest request) } } - private Channel createChannel(int id, ChannelType type) => new Channel(new APIUser()) + private Channel createChannel(int id, ChannelType type) => new Channel(new APIUser { Id = id }) { Id = id, Name = $"Channel {id}", diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index f3ca66538015..c1b5e4ec5ac7 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; @@ -42,7 +43,7 @@ public void TestDetachBeatmapSet() { var beatmapSet = await importer.Import(new ImportTask(TestResources.GetTestBeatmapStream(), "renatus.osz")); - Assert.NotNull(beatmapSet); + ClassicAssert.NotNull(beatmapSet); Debug.Assert(beatmapSet != null); BeatmapSetInfo? detachedBeatmapSet = null; @@ -52,23 +53,23 @@ public void TestDetachBeatmapSet() detachedBeatmapSet = live.Detach(); // files are omitted - Assert.AreEqual(0, detachedBeatmapSet.Files.Count); + ClassicAssert.AreEqual(0, detachedBeatmapSet.Files.Count); - Assert.AreEqual(live.Beatmaps.Count, detachedBeatmapSet.Beatmaps.Count); - Assert.AreEqual(live.Beatmaps.Select(f => f.Difficulty).Count(), detachedBeatmapSet.Beatmaps.Select(f => f.Difficulty).Count()); - Assert.AreEqual(live.Metadata, detachedBeatmapSet.Metadata); + ClassicAssert.AreEqual(live.Beatmaps.Count, detachedBeatmapSet.Beatmaps.Count); + ClassicAssert.AreEqual(live.Beatmaps.Select(f => f.Difficulty).Count(), detachedBeatmapSet.Beatmaps.Select(f => f.Difficulty).Count()); + ClassicAssert.AreEqual(live.Metadata, detachedBeatmapSet.Metadata); }); Debug.Assert(detachedBeatmapSet != null); // Check detached instances can all be accessed without throwing. - Assert.AreEqual(0, detachedBeatmapSet.Files.Count); - Assert.NotNull(detachedBeatmapSet.Beatmaps.Count); - Assert.NotZero(detachedBeatmapSet.Beatmaps.Select(f => f.Difficulty).Count()); - Assert.NotNull(detachedBeatmapSet.Metadata); + ClassicAssert.AreEqual(0, detachedBeatmapSet.Files.Count); + ClassicAssert.NotNull(detachedBeatmapSet.Beatmaps.Count); + ClassicAssert.NotZero(detachedBeatmapSet.Beatmaps.Select(f => f.Difficulty).Count()); + ClassicAssert.NotNull(detachedBeatmapSet.Metadata); // Check cyclic reference to beatmap set - Assert.AreEqual(detachedBeatmapSet, detachedBeatmapSet.Beatmaps.First().BeatmapSet); + ClassicAssert.AreEqual(detachedBeatmapSet, detachedBeatmapSet.Beatmaps.First().BeatmapSet); } }); } @@ -84,7 +85,7 @@ public void TestUpdateDetachedBeatmapSet() { var beatmapSet = await importer.Import(new ImportTask(TestResources.GetTestBeatmapStream(), "renatus.osz")); - Assert.NotNull(beatmapSet); + ClassicAssert.NotNull(beatmapSet); Debug.Assert(beatmapSet != null); // Detach at the BeatmapInfo point, similar to what GetWorkingBeatmap does. @@ -101,28 +102,25 @@ public void TestUpdateDetachedBeatmapSet() detachedBeatmapSet.Beatmaps.First().Metadata.Artist = "New Artist"; detachedBeatmapSet.Beatmaps.First().Metadata.Author = newUser; - Assert.AreNotEqual(detachedBeatmapSet.Status, BeatmapOnlineStatus.Ranked); + ClassicAssert.AreNotEqual(detachedBeatmapSet.Status, BeatmapOnlineStatus.Ranked); detachedBeatmapSet.Status = BeatmapOnlineStatus.Ranked; - beatmapSet.PerformWrite(s => - { - detachedBeatmapSet.CopyChangesToRealm(s); - }); + beatmapSet.PerformWrite(detachedBeatmapSet.CopyChangesToRealm); beatmapSet.PerformRead(s => { // Check above changes explicitly. - Assert.AreEqual(BeatmapOnlineStatus.Ranked, s.Status); - Assert.AreEqual("New Artist", s.Beatmaps.First().Metadata.Artist); - Assert.AreEqual(newUser, s.Beatmaps.First().Metadata.Author); - Assert.NotZero(s.Files.Count); + ClassicAssert.AreEqual(BeatmapOnlineStatus.Ranked, s.Status); + ClassicAssert.AreEqual("New Artist", s.Beatmaps.First().Metadata.Artist); + ClassicAssert.AreEqual(newUser, s.Beatmaps.First().Metadata.Author); + ClassicAssert.NotZero(s.Files.Count); // Check nothing was lost in the copy operation. - Assert.AreEqual(s.Files.Count, detachedBeatmapSet.Files.Count); - Assert.AreEqual(s.Files.Select(f => f.File).Count(), detachedBeatmapSet.Files.Select(f => f.File).Count()); - Assert.AreEqual(s.Beatmaps.Count, detachedBeatmapSet.Beatmaps.Count); - Assert.AreEqual(s.Beatmaps.Select(f => f.Difficulty).Count(), detachedBeatmapSet.Beatmaps.Select(f => f.Difficulty).Count()); - Assert.AreEqual(s.Metadata, detachedBeatmapSet.Metadata); + ClassicAssert.AreEqual(s.Files.Count, detachedBeatmapSet.Files.Count); + ClassicAssert.AreEqual(s.Files.Select(f => f.File).Count(), detachedBeatmapSet.Files.Select(f => f.File).Count()); + ClassicAssert.AreEqual(s.Beatmaps.Count, detachedBeatmapSet.Beatmaps.Count); + ClassicAssert.AreEqual(s.Beatmaps.Select(f => f.Difficulty).Count(), detachedBeatmapSet.Beatmaps.Select(f => f.Difficulty).Count()); + ClassicAssert.AreEqual(s.Metadata, detachedBeatmapSet.Metadata); }); } }); @@ -145,7 +143,7 @@ public void TestAddFileToAsyncImportedBeatmap() { var beatmapSet = await importer.Import(new ImportTask(TestResources.GetTestBeatmapStream(), "renatus.osz")); - Assert.NotNull(beatmapSet); + ClassicAssert.NotNull(beatmapSet); Debug.Assert(beatmapSet != null); // Intentionally detach on async thread as to not trigger a refresh on the main thread. @@ -170,20 +168,20 @@ public void TestImportBeatmapThenCleanup() var imported = await importer.Import(new ImportTask(TestResources.GetTestBeatmapStream(), "renatus.osz")); EnsureLoaded(realm.Realm); - Assert.AreEqual(1, realm.Realm.All().Count()); + ClassicAssert.AreEqual(1, realm.Realm.All().Count()); - Assert.NotNull(imported); + ClassicAssert.NotNull(imported); Debug.Assert(imported != null); imported.PerformWrite(s => s.DeletePending = true); - Assert.AreEqual(1, realm.Realm.All().Count(s => s.DeletePending)); + ClassicAssert.AreEqual(1, realm.Realm.All().Count(s => s.DeletePending)); } }); Logger.Log("Running with no work to purge pending deletions"); - RunTestWithRealm((realm, _) => { Assert.AreEqual(0, realm.Realm.All().Count()); }); + RunTestWithRealm((realm, _) => { ClassicAssert.AreEqual(0, realm.Realm.All().Count()); }); } [Test] @@ -211,8 +209,8 @@ public void TestAccessFileAfterImport() var beatmap = imported.Beatmaps.First(); var file = beatmap.File; - Assert.NotNull(file); - Assert.AreEqual(beatmap.Hash, file!.File.Hash); + ClassicAssert.NotNull(file); + ClassicAssert.AreEqual(beatmap.Hash, file!.File.Hash); }); } @@ -248,10 +246,10 @@ public void TestImportThenDeleteFromStream() EnsureLoaded(realm.Realm); } - Assert.NotNull(importedSet); + ClassicAssert.NotNull(importedSet); Debug.Assert(importedSet != null); - Assert.IsTrue(File.Exists(tempPath), "Stream source file somehow went missing"); + ClassicAssert.True(File.Exists(tempPath), "Stream source file somehow went missing"); File.Delete(tempPath); var imported = realm.Realm.All().First(beatmapSet => beatmapSet.ID == importedSet.ID); @@ -272,8 +270,8 @@ public void TestImportThenImport() var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. - Assert.IsTrue(imported.ID == importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + ClassicAssert.True(imported.ID == importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); checkBeatmapSetCount(realm.Realm, 1); checkSingleReferencedFileCount(realm.Realm, 18); @@ -295,7 +293,7 @@ public void TestImportDirectoryWithEmptyOsuFiles() try { - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(extractedFolder); foreach (var file in new DirectoryInfo(extractedFolder).GetFiles("*.osu")) @@ -307,7 +305,7 @@ public void TestImportDirectoryWithEmptyOsuFiles() } var imported = await importer.Import(new ImportTask(extractedFolder)); - Assert.IsNull(imported); + ClassicAssert.Null(imported); } finally { @@ -336,28 +334,28 @@ public void TestImportThenImportWithReZip() string hashBefore = hashFile(temp); - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(extractedFolder); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); } // zip files differ because different compression or encoder. - Assert.AreNotEqual(hashBefore, hashFile(temp)); + ClassicAssert.AreNotEqual(hashBefore, hashFile(temp)); var importedSecondTime = await importer.Import(new ImportTask(temp)); EnsureLoaded(realm.Realm); - Assert.NotNull(importedSecondTime); + ClassicAssert.NotNull(importedSecondTime); Debug.Assert(importedSecondTime != null); // but contents doesn't, so existing should still be used. - Assert.IsTrue(imported.ID == importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + ClassicAssert.True(imported.ID == importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID == importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); } finally { @@ -385,7 +383,7 @@ public void TestImportThenImportWithChangedHashedFile() await createScoreForBeatmap(realm.Realm, imported.Beatmaps.First()); - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(extractedFolder); // arbitrary write to hashed file @@ -393,7 +391,7 @@ public void TestImportThenImportWithChangedHashedFile() using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.osu").First()).AppendText()) await sw.WriteLineAsync("// changed"); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); @@ -404,11 +402,11 @@ public void TestImportThenImportWithChangedHashedFile() EnsureLoaded(realm.Realm); // check the newly "imported" beatmap is not the original. - Assert.NotNull(importedSecondTime); + ClassicAssert.NotNull(importedSecondTime); Debug.Assert(importedSecondTime != null); - Assert.IsTrue(imported.ID != importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + ClassicAssert.True(imported.ID != importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); } finally { @@ -504,7 +502,7 @@ public void TestImport_ThenModifyMapWithScore_ThenImport() EnsureLoaded(realm.Realm); // check the newly "imported" beatmap is not the original. - Assert.NotNull(importedSecondTime); + ClassicAssert.NotNull(importedSecondTime); Debug.Assert(importedSecondTime != null); Assert.That(imported.ID != importedSecondTime.ID); @@ -536,14 +534,14 @@ public void TestImportThenImportWithChangedFile() { var imported = await LoadOszIntoStore(importer, realm.Realm); - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(extractedFolder); // arbitrary write to non-hashed file using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.mp3").First()).AppendText()) await sw.WriteLineAsync("text"); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); @@ -553,12 +551,12 @@ public void TestImportThenImportWithChangedFile() EnsureLoaded(realm.Realm); - Assert.NotNull(importedSecondTime); + ClassicAssert.NotNull(importedSecondTime); Debug.Assert(importedSecondTime != null); // check the newly "imported" beatmap is not the original. - Assert.IsTrue(imported.ID != importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + ClassicAssert.True(imported.ID != importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); } finally { @@ -584,14 +582,14 @@ public void TestImportThenImportWithDifferentFilename() { var imported = await LoadOszIntoStore(importer, realm.Realm); - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(extractedFolder); // change filename var firstFile = new FileInfo(Directory.GetFiles(extractedFolder).First()); firstFile.MoveTo(Path.Combine(firstFile.DirectoryName.AsNonNull(), $"{firstFile.Name}-changed{firstFile.Extension}")); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); @@ -601,12 +599,12 @@ public void TestImportThenImportWithDifferentFilename() EnsureLoaded(realm.Realm); - Assert.NotNull(importedSecondTime); + ClassicAssert.NotNull(importedSecondTime); Debug.Assert(importedSecondTime != null); // check the newly "imported" beatmap is not the original. - Assert.IsTrue(imported.ID != importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + ClassicAssert.True(imported.ID != importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); } finally { @@ -639,11 +637,11 @@ public void TestImportCorruptThenImport() var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); using (var stream = fileStorage.GetStream(firstFile.File.GetStoragePath())) - Assert.AreEqual(stream.Length, originalLength, "Corruption was not fixed on second import"); + ClassicAssert.AreEqual(stream.Length, originalLength, "Corruption was not fixed on second import"); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. - Assert.IsTrue(imported.ID == importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + ClassicAssert.True(imported.ID == importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); checkBeatmapSetCount(realm.Realm, 1); checkSingleReferencedFileCount(realm.Realm, 18); @@ -662,7 +660,7 @@ public void TestModelCreationFailureDoesntReturn() var zipStream = new MemoryStream(); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) zip.SaveTo(zipStream, new ZipWriterOptions(CompressionType.Deflate)); var imported = await importer.Import( @@ -675,8 +673,8 @@ public void TestModelCreationFailureDoesntReturn() checkBeatmapSetCount(realm.Realm, 0); checkBeatmapCount(realm.Realm, 0); - Assert.IsEmpty(imported); - Assert.AreEqual(ProgressNotificationState.Cancelled, progressNotification.State); + ClassicAssert.IsEmpty(imported); + ClassicAssert.AreEqual(ProgressNotificationState.Cancelled, progressNotification.State); }); } @@ -712,7 +710,7 @@ public void TestRollbackOnFailure() File.Delete(brokenTempFilename); using (var outStream = File.Open(brokenTempFilename, FileMode.CreateNew)) - using (var zip = ZipArchive.Open(brokenOsz)) + using (var zip = ZipArchive.OpenArchive(brokenOsz)) { foreach (var entry in zip.Entries.ToArray()) { @@ -740,7 +738,7 @@ public void TestRollbackOnFailure() checkSingleReferencedFileCount(realm.Realm, 18); - Assert.AreEqual(0, loggedExceptionCount); + ClassicAssert.AreEqual(0, loggedExceptionCount); File.Delete(brokenTempFilename); }); @@ -758,17 +756,17 @@ public void TestImportThenDeleteThenImportOptimisedPath() deleteBeatmapSet(imported, realm.Realm); - Assert.IsTrue(imported.DeletePending); + ClassicAssert.True(imported.DeletePending); var originalAddedDate = imported.DateAdded; var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. - Assert.IsTrue(imported.ID == importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); - Assert.IsFalse(imported.DeletePending); - Assert.IsFalse(importedSecondTime.DeletePending); + ClassicAssert.True(imported.ID == importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + ClassicAssert.False(imported.DeletePending); + ClassicAssert.False(importedSecondTime.DeletePending); Assert.That(importedSecondTime.DateAdded, Is.GreaterThan(originalAddedDate)); }); } @@ -790,13 +788,13 @@ public void TestImportThenReimportWithNewDifficulty() try { - using (var zip = ZipArchive.Open(pathOriginal)) + using (var zip = ZipArchive.OpenArchive(pathOriginal)) zip.WriteToDirectory(extractedFolder); // remove one difficulty before first import new FileInfo(Directory.GetFiles(extractedFolder, "*.osu").First()).Delete(); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.SaveTo(pathMissingOneBeatmap, new ZipWriterOptions(CompressionType.Deflate)); @@ -844,7 +842,7 @@ public void TestImportThenReimportAfterMissingFiles() deleteBeatmapSet(imported, realmFactory.Realm); - Assert.IsTrue(imported.DeletePending); + ClassicAssert.True(imported.DeletePending); // intentionally nuke all files storage.DeleteDirectory("files"); @@ -854,10 +852,10 @@ public void TestImportThenReimportAfterMissingFiles() var importedSecondTime = await LoadOszIntoStore(importer, realmFactory.Realm); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. - Assert.IsTrue(imported.ID == importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); - Assert.IsFalse(imported.DeletePending); - Assert.IsFalse(importedSecondTime.DeletePending); + ClassicAssert.True(imported.ID == importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + ClassicAssert.False(imported.DeletePending); + ClassicAssert.False(importedSecondTime.DeletePending); // check that the files now exist, even though they were deleted above. Assert.That(importedSecondTime.Files.All(f => storage.GetStorageForDirectory("files").Exists(f.File.GetStoragePath()))); @@ -876,17 +874,17 @@ public void TestImportThenDeleteThenImportNonOptimisedPath() deleteBeatmapSet(imported, realm.Realm); - Assert.IsTrue(imported.DeletePending); + ClassicAssert.True(imported.DeletePending); var originalAddedDate = imported.DateAdded; var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. - Assert.IsTrue(imported.ID == importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); - Assert.IsFalse(imported.DeletePending); - Assert.IsFalse(importedSecondTime.DeletePending); + ClassicAssert.True(imported.ID == importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + ClassicAssert.False(imported.DeletePending); + ClassicAssert.False(importedSecondTime.DeletePending); Assert.That(importedSecondTime.DateAdded, Is.GreaterThan(originalAddedDate)); }); } @@ -912,8 +910,8 @@ await realm.Realm.WriteAsync(() => var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); // check the newly "imported" beatmap has been reimported due to mismatch (even though hashes matched) - Assert.IsTrue(imported.ID != importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Beatmaps.First().ID); + ClassicAssert.True(imported.ID != importedSecondTime.ID); + ClassicAssert.True(imported.Beatmaps.First().ID != importedSecondTime.Beatmaps.First().ID); }); } @@ -957,11 +955,11 @@ public void TestImportWithDuplicateBeatmapIDs() realm.Run(r => r.Refresh()); - Assert.NotNull(imported); + ClassicAssert.NotNull(imported); Debug.Assert(imported != null); - Assert.AreEqual(-1, imported.PerformRead(s => s.Beatmaps[0].OnlineID)); - Assert.AreEqual(-1, imported.PerformRead(s => s.Beatmaps[1].OnlineID)); + ClassicAssert.AreEqual(-1, imported.PerformRead(s => s.Beatmaps[0].OnlineID)); + ClassicAssert.AreEqual(-1, imported.PerformRead(s => s.Beatmaps[1].OnlineID)); }); } @@ -978,7 +976,7 @@ public void TestImportWhenFileOpen() await importer.Import(temp); EnsureLoaded(realm.Realm); File.Delete(temp); - Assert.IsFalse(File.Exists(temp), "We likely held a read lock on the file when we shouldn't"); + ClassicAssert.False(File.Exists(temp), "We likely held a read lock on the file when we shouldn't"); }); } @@ -997,10 +995,10 @@ public void TestImportWithDuplicateHashes() try { - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(extractedFolder); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.AddEntry("duplicate.osu", Directory.GetFiles(extractedFolder, "*.osu").First()); @@ -1033,7 +1031,7 @@ public void TestBeatmapFilesInNestedDirectoriesAreIgnored() try { - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(extractedFolder); var subdirectory = Directory.CreateDirectory(Path.Combine(extractedFolder, "subdir")); @@ -1044,7 +1042,7 @@ public void TestBeatmapFilesInNestedDirectoriesAreIgnored() using (var textWriter = new StreamWriter(stream)) await textWriter.WriteLineAsync("# adding a comment so that the hashes are different"); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); @@ -1078,10 +1076,10 @@ public void TestImportNestedStructure() try { - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(subfolder); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); @@ -1089,12 +1087,12 @@ public void TestImportNestedStructure() var imported = await importer.Import(new ImportTask(temp)); - Assert.NotNull(imported); + ClassicAssert.NotNull(imported); Debug.Assert(imported != null); EnsureLoaded(realm.Realm); - Assert.IsFalse(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("subfolder"))), "Files contain common subfolder"); + ClassicAssert.False(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("subfolder"))), "Files contain common subfolder"); } finally { @@ -1128,10 +1126,10 @@ public void TestImportWithIgnoredDirectoryInArchive() try { - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(dataFolder); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); @@ -1139,13 +1137,13 @@ public void TestImportWithIgnoredDirectoryInArchive() var imported = await importer.Import(new ImportTask(temp)); - Assert.NotNull(imported); + ClassicAssert.NotNull(imported); Debug.Assert(imported != null); EnsureLoaded(realm.Realm); - Assert.IsFalse(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("__MACOSX"))), "Files contain resource fork folder, which should be ignored"); - Assert.IsFalse(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("actual_data"))), "Files contain common subfolder"); + ClassicAssert.False(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("__MACOSX"))), "Files contain resource fork folder, which should be ignored"); + ClassicAssert.False(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("actual_data"))), "Files contain common subfolder"); } finally { @@ -1185,7 +1183,7 @@ public void TestUpdateBeatmapInfo() var importedSet = await importer.Import(new ImportTask(temp)); - Assert.NotNull(importedSet); + ClassicAssert.NotNull(importedSet); EnsureLoaded(realm); @@ -1200,7 +1198,7 @@ public static async Task LoadOszIntoStore(BeatmapImporter import var importedSet = await importer.Import(new ImportTask(temp), new ImportParameters { Batch = batchImport }); - Assert.NotNull(importedSet); + ClassicAssert.NotNull(importedSet); Debug.Assert(importedSet != null); EnsureLoaded(realm); @@ -1217,7 +1215,7 @@ private void deleteBeatmapSet(BeatmapSetInfo imported, Realm realm) checkBeatmapSetCount(realm, 0); checkBeatmapSetCount(realm, 1, true); - Assert.IsTrue(realm.All().First(_ => true).DeletePending); + ClassicAssert.True(realm.All().First(_ => true).DeletePending); } private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap) => @@ -1233,7 +1231,7 @@ private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap) => private static void checkBeatmapSetCount(Realm realm, int expected, bool includeDeletePending = false) { - Assert.AreEqual(expected, includeDeletePending + ClassicAssert.AreEqual(expected, includeDeletePending ? realm.All().Count() : realm.All().Count(s => !s.DeletePending)); } @@ -1246,7 +1244,7 @@ private static string hashFile(string filename) private static void checkBeatmapCount(Realm realm, int expected) { - Assert.AreEqual(expected, realm.All().Where(_ => true).ToList().Count); + ClassicAssert.AreEqual(expected, realm.All().Where(_ => true).ToList().Count); } private static void checkSingleReferencedFileCount(Realm realm, int expected) @@ -1259,7 +1257,7 @@ private static void checkSingleReferencedFileCount(Realm realm, int expected) singleReferencedCount++; } - Assert.AreEqual(expected, singleReferencedCount); + ClassicAssert.AreEqual(expected, singleReferencedCount); } internal static void EnsureLoaded(Realm realm, int timeout = 60000) @@ -1273,7 +1271,7 @@ internal static void EnsureLoaded(Realm realm, int timeout = 60000) }, @"BeatmapSet did not import to the database in allocated time.", timeout); // ensure we were stored to beatmap database backing... - Assert.IsTrue(resultSets?.Count() == 1, $@"Incorrect result count found ({resultSets?.Count()} but should be 1)."); + ClassicAssert.True(resultSets?.Count() == 1, $@"Incorrect result count found ({resultSets?.Count()} but should be 1)."); IEnumerable queryBeatmapSets() => realm.All().Where(s => !s.DeletePending && s.OnlineID == 241526); @@ -1282,20 +1280,20 @@ internal static void EnsureLoaded(Realm realm, int timeout = 60000) // ReSharper disable once PossibleUnintendedReferenceComparison IEnumerable queryBeatmaps() => realm.All().Where(s => s.BeatmapSet != null && s.BeatmapSet == set); - Assert.AreEqual(12, queryBeatmaps().Count(), @"Beatmap count was not correct"); - Assert.AreEqual(1, queryBeatmapSets().Count(), @"Beatmapset count was not correct"); + ClassicAssert.AreEqual(12, queryBeatmaps().Count(), @"Beatmap count was not correct"); + ClassicAssert.AreEqual(1, queryBeatmapSets().Count(), @"Beatmapset count was not correct"); int countBeatmapSetBeatmaps; int countBeatmaps; - Assert.AreEqual( + ClassicAssert.AreEqual( countBeatmapSetBeatmaps = queryBeatmapSets().First().Beatmaps.Count, countBeatmaps = queryBeatmaps().Count(), $@"Incorrect database beatmap count post-import ({countBeatmaps} but should be {countBeatmapSetBeatmaps})."); foreach (BeatmapInfo b in set.Beatmaps) - Assert.IsTrue(set.Beatmaps.Any(c => c.OnlineID == b.OnlineID)); - Assert.IsTrue(set.Beatmaps.Count > 0); + ClassicAssert.True(set.Beatmaps.Any(c => c.OnlineID == b.OnlineID)); + ClassicAssert.True(set.Beatmaps.Count > 0); } private static void waitForOrAssert(Func result, string failureMessage, int timeout = 60000) diff --git a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs index 3f1bc5814721..016658f68bb8 100644 --- a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs @@ -680,14 +680,14 @@ private static IDisposable getBeatmapArchiveWithModifications(out string path, A string extractedFolder = $"{path}_extracted"; Directory.CreateDirectory(extractedFolder); - using (var zip = ZipArchive.Open(path)) + using (var zip = ZipArchive.OpenArchive(path)) zip.WriteToDirectory(extractedFolder); applyModifications(new DirectoryInfo(extractedFolder)); File.Delete(path); - using (var zip = ZipArchive.Create()) + using (var zip = ZipArchive.CreateArchive()) { zip.AddAllFromDirectory(extractedFolder); zip.SaveTo(path, new ZipWriterOptions(CompressionType.Deflate)); diff --git a/osu.Game.Tests/Database/FileStoreTests.cs b/osu.Game.Tests/Database/FileStoreTests.cs index ab9b761b8fb7..243d4eebcd06 100644 --- a/osu.Game.Tests/Database/FileStoreTests.cs +++ b/osu.Game.Tests/Database/FileStoreTests.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Logging; using osu.Game.Database; using osu.Game.Extensions; @@ -26,8 +27,8 @@ public void TestImportFile() realm.Write(() => files.Add(testData, realm)); - Assert.True(files.Storage.Exists("0/05/054edec1d0211f624fed0cbca9d4f9400b0e491c43742af2c5b0abebf0c990d8")); - Assert.True(files.Storage.Exists(realm.All().First().GetStoragePath())); + ClassicAssert.True(files.Storage.Exists("0/05/054edec1d0211f624fed0cbca9d4f9400b0e491c43742af2c5b0abebf0c990d8")); + ClassicAssert.True(files.Storage.Exists(realm.All().First().GetStoragePath())); }); } @@ -44,7 +45,7 @@ public void TestImportSameFileTwice() realm.Write(() => files.Add(testData, realm)); realm.Write(() => files.Add(testData, realm)); - Assert.AreEqual(1, realm.All().Count()); + ClassicAssert.AreEqual(1, realm.All().Count()); }); } @@ -75,15 +76,15 @@ public void TestDontPurgeReferenced() string path = file.GetStoragePath(); - Assert.True(realm.All().Any()); - Assert.True(files.Storage.Exists(path)); + ClassicAssert.True(realm.All().Any()); + ClassicAssert.True(files.Storage.Exists(path)); files.Cleanup(); Logger.Log($"Cleanup complete at {timer.ElapsedMilliseconds}"); - Assert.True(realm.All().Any()); - Assert.True(file.IsValid); - Assert.True(files.Storage.Exists(path)); + ClassicAssert.True(realm.All().Any()); + ClassicAssert.True(file.IsValid); + ClassicAssert.True(files.Storage.Exists(path)); }); } @@ -99,14 +100,14 @@ public void TestPurgeUnreferenced() string path = file.GetStoragePath(); - Assert.True(realm.All().Any()); - Assert.True(files.Storage.Exists(path)); + ClassicAssert.True(realm.All().Any()); + ClassicAssert.True(files.Storage.Exists(path)); files.Cleanup(); - Assert.False(realm.All().Any()); - Assert.False(file.IsValid); - Assert.False(files.Storage.Exists(path)); + ClassicAssert.False(realm.All().Any()); + ClassicAssert.False(file.IsValid); + ClassicAssert.False(files.Storage.Exists(path)); }); } } diff --git a/osu.Game.Tests/Database/GeneralUsageTests.cs b/osu.Game.Tests/Database/GeneralUsageTests.cs index b8073a65bc15..3550d39ab8b5 100644 --- a/osu.Game.Tests/Database/GeneralUsageTests.cs +++ b/osu.Game.Tests/Database/GeneralUsageTests.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Database; @@ -143,7 +144,7 @@ public void TestNestedContextCreationWithSubscription() return null; }); - Assert.IsTrue(callbackRan); + ClassicAssert.True(callbackRan); }); } diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs index 016928c6d610..80be6fcfef54 100644 --- a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs +++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs @@ -7,6 +7,7 @@ using System.IO.Compression; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -93,8 +94,8 @@ public void TestStableDateAddedApplied() var importedSet = realm.Realm.All().Single(); - Assert.NotNull(importedSet); - Assert.AreEqual(new DateTimeOffset(new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)), importedSet.DateAdded); + ClassicAssert.NotNull(importedSet); + ClassicAssert.AreEqual(new DateTimeOffset(new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)), importedSet.DateAdded); } }); } diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs index cea30acf3f78..5035ff68c99d 100644 --- a/osu.Game.Tests/Database/RealmLiveTests.cs +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Extensions; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -25,7 +26,7 @@ public void TestLiveEquality() Live beatmap2 = realm.Run(r => r.All().First().ToLive(realm)); - Assert.AreEqual(beatmap, beatmap2); + ClassicAssert.AreEqual(beatmap, beatmap2); }); } @@ -52,7 +53,7 @@ public void TestAccessAfterStorageMigrate() using (realm.BlockAllOperations("testing")) storage.Migrate(migratedStorage); - Assert.IsFalse(liveBeatmap?.PerformRead(l => l.Hidden)); + ClassicAssert.False(liveBeatmap?.PerformRead(l => l.Hidden)); }); } } @@ -111,7 +112,7 @@ public void TestNestedWriteCalls() r.Add(beatmap))) ); - Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); + ClassicAssert.False(liveBeatmap.PerformRead(l => l.Hidden)); }); } @@ -126,7 +127,7 @@ public void TestAccessAfterAttach() realm.Run(r => r.Write(_ => r.Add(beatmap))); - Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); + ClassicAssert.False(liveBeatmap.PerformRead(l => l.Hidden)); }); } @@ -136,15 +137,15 @@ public void TestAccessNonManaged() var beatmap = new BeatmapInfo(CreateRuleset(), new BeatmapDifficulty(), new BeatmapMetadata()); var liveBeatmap = beatmap.ToLiveUnmanaged(); - Assert.IsFalse(beatmap.Hidden); - Assert.IsFalse(liveBeatmap.Value.Hidden); - Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); + ClassicAssert.False(beatmap.Hidden); + ClassicAssert.False(liveBeatmap.Value.Hidden); + ClassicAssert.False(liveBeatmap.PerformRead(l => l.Hidden)); Assert.Throws(() => liveBeatmap.PerformWrite(l => l.Hidden = true)); - Assert.IsFalse(beatmap.Hidden); - Assert.IsFalse(liveBeatmap.Value.Hidden); - Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); + ClassicAssert.False(beatmap.Hidden); + ClassicAssert.False(liveBeatmap.Value.Hidden); + ClassicAssert.False(liveBeatmap.PerformRead(l => l.Hidden)); } [Test] @@ -159,10 +160,10 @@ public void TestTransactionRolledBackOnException() var liveBeatmap = beatmap.ToLive(realm); Assert.Throws(() => liveBeatmap.PerformWrite(l => throw new InvalidOperationException())); - Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); + ClassicAssert.False(liveBeatmap.PerformRead(l => l.Hidden)); liveBeatmap.PerformWrite(l => l.Hidden = true); - Assert.IsTrue(liveBeatmap.PerformRead(l => l.Hidden)); + ClassicAssert.True(liveBeatmap.PerformRead(l => l.Hidden)); }); } @@ -188,8 +189,8 @@ public void TestScopedReadWithoutContext() { liveBeatmap.PerformRead(beatmap => { - Assert.IsTrue(beatmap.IsValid); - Assert.IsFalse(beatmap.Hidden); + ClassicAssert.True(beatmap.IsValid); + ClassicAssert.False(beatmap.Hidden); }); }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); }); @@ -216,7 +217,7 @@ public void TestScopedWriteWithoutContext() Task.Factory.StartNew(() => { liveBeatmap.PerformWrite(beatmap => { beatmap.Hidden = true; }); - liveBeatmap.PerformRead(beatmap => { Assert.IsTrue(beatmap.Hidden); }); + liveBeatmap.PerformRead(beatmap => { ClassicAssert.True(beatmap.Hidden); }); }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).WaitSafely(); }); } @@ -333,17 +334,17 @@ public void TestLiveAssumptions() Debug.Assert(liveBeatmap != null); // not yet seen by main context - Assert.AreEqual(0, outerRealm.All().Count()); - Assert.AreEqual(0, changesTriggered); + ClassicAssert.AreEqual(0, outerRealm.All().Count()); + ClassicAssert.AreEqual(0, changesTriggered); liveBeatmap.PerformRead(resolved => { // retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point. - Assert.AreEqual(2, outerRealm.All().Count()); - Assert.AreEqual(1, changesTriggered); + ClassicAssert.AreEqual(2, outerRealm.All().Count()); + ClassicAssert.AreEqual(1, changesTriggered); // can access properties without a crash. - Assert.IsFalse(resolved.Hidden); + ClassicAssert.False(resolved.Hidden); outerRealm.Write(r => { diff --git a/osu.Game.Tests/Database/RulesetStoreTests.cs b/osu.Game.Tests/Database/RulesetStoreTests.cs index 29aec737704e..c8e2a42d8179 100644 --- a/osu.Game.Tests/Database/RulesetStoreTests.cs +++ b/osu.Game.Tests/Database/RulesetStoreTests.cs @@ -5,8 +5,10 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; @@ -26,8 +28,8 @@ public void TestCreateStore() { using var rulesets = new RealmRulesetStore(realm, storage); - Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); - Assert.AreEqual(4, realm.Realm.All().Count()); + ClassicAssert.AreEqual(4, rulesets.AvailableRulesets.Count()); + ClassicAssert.AreEqual(4, realm.Realm.All().Count()); }); } @@ -39,11 +41,11 @@ public void TestCreateStoreTwiceDoesntAddRulesetsAgain() using var rulesets = new RealmRulesetStore(realm, storage); using var rulesets2 = new RealmRulesetStore(realm, storage); - Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); - Assert.AreEqual(4, rulesets2.AvailableRulesets.Count()); + ClassicAssert.AreEqual(4, rulesets.AvailableRulesets.Count()); + ClassicAssert.AreEqual(4, rulesets2.AvailableRulesets.Count()); - Assert.AreEqual(rulesets.AvailableRulesets.First(), rulesets2.AvailableRulesets.First()); - Assert.AreEqual(4, realm.Realm.All().Count()); + ClassicAssert.AreEqual(rulesets.AvailableRulesets.First(), rulesets2.AvailableRulesets.First()); + ClassicAssert.AreEqual(4, realm.Realm.All().Count()); }); } @@ -54,9 +56,9 @@ public void TestRetrievedRulesetsAreDetached() { using var rulesets = new RealmRulesetStore(realm, storage); - Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged); - Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged); - Assert.IsFalse(rulesets.GetRuleset("mania")?.IsManaged); + ClassicAssert.False(rulesets.AvailableRulesets.First().IsManaged); + ClassicAssert.False(rulesets.GetRuleset(0)?.IsManaged); + ClassicAssert.False(rulesets.GetRuleset("mania")?.IsManaged); }); } @@ -116,6 +118,69 @@ public void TestOutdatedRulesetNotAvailable() }); } + [Test] + public void TestFakedRulesetIdIsDetected() + { + RunTestWithRealm((realm, storage) => + { + LoadTestRuleset.HasImplementations = true; + LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; + + var ruleset = new LoadTestRuleset(); + string rulesetShortName = ruleset.RulesetInfo.ShortName; + + realm.Write(r => r.Add(new RulesetInfo(rulesetShortName, ruleset.RulesetInfo.Name, ruleset.RulesetInfo.InstantiationInfo, 0) + { + Available = true, + })); + + Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.True); + + // Availability is updated on construction of a RealmRulesetStore + using var _ = new RealmRulesetStore(realm, storage); + + Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.False); + }); + } + + [Test] + public void TestMultipleRulesetWithSameOnlineIdsAreDetected() + { + RunTestWithRealm((realm, storage) => + { + LoadTestRuleset.HasImplementations = true; + LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; + LoadTestRuleset.OnlineID = 2; + + var first = new LoadTestRuleset(); + var second = new CatchRuleset(); + + realm.Write(r => r.Add(new RulesetInfo(first.ShortName, first.RulesetInfo.Name, first.RulesetInfo.InstantiationInfo, first.RulesetInfo.OnlineID) + { + Available = true, + })); + realm.Write(r => r.Add(new RulesetInfo(second.ShortName, second.RulesetInfo.Name, second.RulesetInfo.InstantiationInfo, second.RulesetInfo.OnlineID) + { + Available = true, + })); + + Assert.That(realm.Run(r => r.Find(first.ShortName)!.Available), Is.True); + Assert.That(realm.Run(r => r.Find(second.ShortName)!.Available), Is.True); + + // Availability is updated on construction of a RealmRulesetStore + using var _ = new RealmRulesetStore(realm, storage); + + Assert.That(realm.Run(r => r.Find(first.ShortName)!.Available), Is.False); + Assert.That(realm.Run(r => r.Find(second.ShortName)!.Available), Is.False); + + realm.Write(r => r.Remove(r.Find(first.ShortName)!)); + + using var __ = new RealmRulesetStore(realm, storage); + + Assert.That(realm.Run(r => r.Find(second.ShortName)!.Available), Is.True); + }); + } + private class LoadTestRuleset : Ruleset { public override string RulesetAPIVersionSupported => Version; @@ -124,6 +189,13 @@ private class LoadTestRuleset : Ruleset public static string Version { get; set; } = CURRENT_RULESET_API_VERSION; + public static int OnlineID { get; set; } = -1; + + public LoadTestRuleset() + { + RulesetInfo.OnlineID = OnlineID; + } + public override IEnumerable GetModsFor(ModType type) { if (!HasImplementations) diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs index e2774cef006e..004a89ddecdb 100644 --- a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs +++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs @@ -3,14 +3,11 @@ #nullable disable -using System; using System.Collections.Generic; -using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Input; using osu.Framework.Input.Bindings; -using osu.Framework.Platform; using osu.Game.Database; using osu.Game.Input; using osu.Game.Input.Bindings; @@ -20,107 +17,95 @@ namespace osu.Game.Tests.Database { [TestFixture] - public partial class TestRealmKeyBindingStore + public partial class TestRealmKeyBindingStore : RealmTest { - private NativeStorage storage; - - private RealmKeyBindingStore keyBindingStore; - - private RealmAccess realm; - - [SetUp] - public void SetUp() - { - var directory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); - - storage = new NativeStorage(directory.FullName); - - realm = new RealmAccess(storage, "test"); - keyBindingStore = new RealmKeyBindingStore(realm, new ReadableKeyCombinationProvider()); - } - [Test] public void TestDefaultsPopulationAndQuery() { - Assert.That(queryCount(), Is.EqualTo(0)); + RunTestWithRealm((realm, _) => + { + Assert.That(queryCount(realm), Is.EqualTo(0)); - KeyBindingContainer testContainer = new TestKeyBindingContainer(); + KeyBindingContainer testContainer = new TestKeyBindingContainer(); - keyBindingStore.Register(testContainer, Enumerable.Empty()); + var keyBindingStore = new RealmKeyBindingStore(realm, new ReadableKeyCombinationProvider()); + keyBindingStore.Register(testContainer, Enumerable.Empty()); - Assert.That(queryCount(), Is.EqualTo(3)); + Assert.That(queryCount(realm), Is.EqualTo(3)); - Assert.That(queryCount(GlobalAction.Back), Is.EqualTo(1)); - Assert.That(queryCount(GlobalAction.Select), Is.EqualTo(2)); + Assert.That(queryCount(realm, GlobalAction.Back), Is.EqualTo(1)); + Assert.That(queryCount(realm, GlobalAction.Select), Is.EqualTo(2)); + }); } [Test] public void TestDefaultsPopulationRemovesExcess() { - Assert.That(queryCount(), Is.EqualTo(0)); - - KeyBindingContainer testContainer = new TestKeyBindingContainer(); - - // Add some excess bindings for an action which only supports 1. - realm.Write(r => + RunTestWithRealm((realm, _) => { - r.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.A))); - r.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.S))); - r.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.D))); - }); + Assert.That(queryCount(realm), Is.EqualTo(0)); - Assert.That(queryCount(GlobalAction.Back), Is.EqualTo(3)); + KeyBindingContainer testContainer = new TestKeyBindingContainer(); - keyBindingStore.Register(testContainer, Enumerable.Empty()); + // Add some excess bindings for an action which only supports 1. + realm.Write(r => + { + r.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.A))); + r.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.S))); + r.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.D))); + }); - Assert.That(queryCount(GlobalAction.Back), Is.EqualTo(1)); - } + Assert.That(queryCount(realm, GlobalAction.Back), Is.EqualTo(3)); - private int queryCount(GlobalAction? match = null) - { - return realm.Run(r => - { - var results = r.All(); - if (match.HasValue) - results = results.Where(k => k.ActionInt == (int)match.Value); - return results.Count(); + var keyBindingStore = new RealmKeyBindingStore(realm, new ReadableKeyCombinationProvider()); + keyBindingStore.Register(testContainer, Enumerable.Empty()); + + Assert.That(queryCount(realm, GlobalAction.Back), Is.EqualTo(1)); }); } [Test] public void TestUpdateViaQueriedReference() { - KeyBindingContainer testContainer = new TestKeyBindingContainer(); + RunTestWithRealm((realm, _) => + { + KeyBindingContainer testContainer = new TestKeyBindingContainer(); - keyBindingStore.Register(testContainer, Enumerable.Empty()); + var keyBindingStore = new RealmKeyBindingStore(realm, new ReadableKeyCombinationProvider()); + keyBindingStore.Register(testContainer, Enumerable.Empty()); - realm.Run(outerRealm => - { - var backBinding = outerRealm.All().Single(k => k.ActionInt == (int)GlobalAction.Back); + realm.Run(outerRealm => + { + var backBinding = outerRealm.All().Single(k => k.ActionInt == (int)GlobalAction.Back); - Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape })); + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape })); - var tsr = ThreadSafeReference.Create(backBinding); + var tsr = ThreadSafeReference.Create(backBinding); - realm.Run(innerRealm => - { - var binding = innerRealm.ResolveReference(tsr)!; - innerRealm.Write(() => binding.KeyCombination = new KeyCombination(InputKey.BackSpace)); - }); + realm.Run(innerRealm => + { + var binding = innerRealm.ResolveReference(tsr)!; + innerRealm.Write(() => binding.KeyCombination = new KeyCombination(InputKey.BackSpace)); + }); - Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); - // check still correct after re-query. - backBinding = outerRealm.All().Single(k => k.ActionInt == (int)GlobalAction.Back); - Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); + // check still correct after re-query. + backBinding = outerRealm.All().Single(k => k.ActionInt == (int)GlobalAction.Back); + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); + }); }); } - [TearDown] - public void TearDown() + private static int queryCount(RealmAccess realm, GlobalAction? match = null) { - realm.Dispose(); - storage.DeleteDirectory(string.Empty); + return realm.Run(r => + { + var results = r.All(); + if (match.HasValue) + results = results.Where(k => k.ActionInt == (int)match.Value); + return results.Count(); + }); } public partial class TestKeyBindingContainer : KeyBindingContainer diff --git a/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs b/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs index 9774a8ebb69c..ac04f6c4ffb2 100644 --- a/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs @@ -5,6 +5,7 @@ using System.Linq; using Moq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks; @@ -44,7 +45,7 @@ public void Setup() public void TestRegularVideoFile() { using (var resourceStream = TestResources.OpenResource("Videos/test-video.mp4")) - Assert.IsEmpty(check.Run(getContext(resourceStream))); + ClassicAssert.IsEmpty(check.Run(getContext(resourceStream))); } [Test] @@ -88,7 +89,7 @@ private BeatmapVerifierContext getContext(Stream? resourceStream) var layer = storyboard.GetLayer("Video"); layer.Add(new StoryboardVideo("abc123.mp4", 0)); - var mockWorkingBeatmap = new Mock(beatmap, null, null); + var mockWorkingBeatmap = new Mock(beatmap, null!, null!); mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); mockWorkingBeatmap.As().SetupGet(w => w.Storyboard).Returns(storyboard); diff --git a/osu.Game.Tests/Editing/Checks/CheckDelayedHitsoundsTest.cs b/osu.Game.Tests/Editing/Checks/CheckDelayedHitsoundsTest.cs index 20b9643ab449..1cc89587a210 100644 --- a/osu.Game.Tests/Editing/Checks/CheckDelayedHitsoundsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckDelayedHitsoundsTest.cs @@ -6,6 +6,7 @@ using ManagedBass; using Moq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Models; using osu.Game.Rulesets.Edit; @@ -49,7 +50,7 @@ public void SetUp() public void TestNoDelayedHitsounds() { using var resourceStream = TestResources.OpenResource("Samples/hitsound-no-delay.wav"); - Assert.IsEmpty(check.Run(getContext(resourceStream))); + ClassicAssert.IsEmpty(check.Run(getContext(resourceStream))); } [Test] @@ -96,7 +97,7 @@ public void TestConsequentlyDelayedHitsounds() private BeatmapVerifierContext getContext(Stream? resourceStream) { - var mockWorkingBeatmap = new Mock(beatmap, null, null); + var mockWorkingBeatmap = new Mock(beatmap, null!, null!); mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); diff --git a/osu.Game.Tests/Editing/Checks/CheckHitsoundsFormatTest.cs b/osu.Game.Tests/Editing/Checks/CheckHitsoundsFormatTest.cs index 6da391aa8dd4..0a6e0455926d 100644 --- a/osu.Game.Tests/Editing/Checks/CheckHitsoundsFormatTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckHitsoundsFormatTest.cs @@ -139,7 +139,7 @@ public void TestBeatmapAudioTracksExemptedFromCheck() Metadata = new BeatmapMetadata { AudioFile = beatmapSet.Files[0].Filename } } }; - var firstWorking = new Mock(firstPlayable, null, null); + var firstWorking = new Mock(firstPlayable, null!, null!); firstWorking.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); var secondPlayable = new Beatmap @@ -150,7 +150,7 @@ public void TestBeatmapAudioTracksExemptedFromCheck() Metadata = new BeatmapMetadata { AudioFile = beatmapSet.Files[1].Filename } } }; - var secondWorking = new Mock(secondPlayable, null, null); + var secondWorking = new Mock(secondPlayable, null!, null!); secondWorking.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); var context = new BeatmapVerifierContext( @@ -165,7 +165,7 @@ [new BeatmapVerifierContext.VerifiedBeatmap(secondWorking.Object, secondPlayable private BeatmapVerifierContext getContext(Stream? resourceStream) { - var mockWorkingBeatmap = new Mock(beatmap, null, null); + var mockWorkingBeatmap = new Mock(beatmap, null!, null!); mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); diff --git a/osu.Game.Tests/Editing/Checks/CheckSongFormatTest.cs b/osu.Game.Tests/Editing/Checks/CheckSongFormatTest.cs index 98a4e1f9e95b..aab634a47f8f 100644 --- a/osu.Game.Tests/Editing/Checks/CheckSongFormatTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckSongFormatTest.cs @@ -103,7 +103,7 @@ public void TestCorruptAudio() private BeatmapVerifierContext getContext(Stream? resourceStream) { - var mockWorkingBeatmap = new Mock(beatmap, null, null); + var mockWorkingBeatmap = new Mock(beatmap, null!, null!); mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); diff --git a/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs b/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs index b646e639556f..5ff786825b17 100644 --- a/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs @@ -7,6 +7,7 @@ using ManagedBass; using Moq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks; @@ -52,7 +53,7 @@ public void TestDifferentExtension() beatmap.BeatmapInfo.BeatmapSet.Files.Add(CheckTestHelpers.CreateMockFile("jpg")); // Should fail to load, but not produce an error due to the extension not being expected to load. - Assert.IsEmpty(check.Run(getContext(null))); + ClassicAssert.IsEmpty(check.Run(getContext(null))); } [Test] @@ -60,7 +61,7 @@ public void TestRegularAudioFile() { using (var resourceStream = TestResources.OpenResource("Samples/test-sample.mp3")) { - Assert.IsEmpty(check.Run(getContext(resourceStream))); + ClassicAssert.IsEmpty(check.Run(getContext(resourceStream))); } } @@ -70,7 +71,7 @@ public void TestBlankAudioFile() using (var resourceStream = TestResources.OpenResource("Samples/blank.wav")) { // This is a 0 ms duration audio file, commonly used to silence sliderslides/ticks, and so should be fine. - Assert.IsEmpty(check.Run(getContext(resourceStream))); + ClassicAssert.IsEmpty(check.Run(getContext(resourceStream))); } } @@ -91,13 +92,13 @@ public void TestMissingAudioFile() { using (var resourceStream = TestResources.OpenResource("Samples/missing.mp3")) { - Assert.IsEmpty(check.Run(getContext(resourceStream))); + ClassicAssert.IsEmpty(check.Run(getContext(resourceStream))); } } private BeatmapVerifierContext getContext(Stream? resourceStream) { - var mockWorkingBeatmap = new Mock(beatmap, null, null); + var mockWorkingBeatmap = new Mock(beatmap, null!, null!); mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); diff --git a/osu.Game.Tests/Editing/Checks/CheckVideoResolutionTest.cs b/osu.Game.Tests/Editing/Checks/CheckVideoResolutionTest.cs index 1e16c67aabc6..3241e489f8da 100644 --- a/osu.Game.Tests/Editing/Checks/CheckVideoResolutionTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckVideoResolutionTest.cs @@ -79,7 +79,7 @@ private BeatmapVerifierContext getContext(Stream? resourceStream) var layer = storyboard.GetLayer("Video"); layer.Add(new StoryboardVideo("abc123.mp4", 0)); - var mockWorkingBeatmap = new Mock(beatmap, null, null); + var mockWorkingBeatmap = new Mock(beatmap, null!, null!); mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); mockWorkingBeatmap.As().SetupGet(w => w.Storyboard).Returns(storyboard); diff --git a/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs b/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs index a39ef22b723f..1235292511b4 100644 --- a/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs @@ -5,6 +5,7 @@ using System.Linq; using Moq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks; @@ -40,7 +41,7 @@ public void Setup() [Test] public void TestNonZeroBytes() { - Assert.IsEmpty(check.Run(getContext(byteLength: 44))); + ClassicAssert.IsEmpty(check.Run(getContext(byteLength: 44))); } [Test] @@ -55,7 +56,7 @@ public void TestZeroBytes() [Test] public void TestMissing() { - Assert.IsEmpty(check.Run(getContextMissing())); + ClassicAssert.IsEmpty(check.Run(getContextMissing())); } private BeatmapVerifierContext getContext(long byteLength) diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs index c9f5f5023240..20d63b9bb471 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs @@ -126,6 +126,22 @@ public void TestFileSampleFromBeatmap() AssertBeatmapLookup(expected_sample); } + /// + /// Tests that a hitobject which specifies a specific sample file which doesn't exist (or isn't allowed to be looked up) + /// falls back to a normal sample. + /// + [Test] + public void TestFileSampleFallsBackToNormal() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(null, expected_sample); + + CreateTestWithBeatmap("file-beatmap-sample.osu"); + + AssertUserLookup(expected_sample); + } + /// /// Tests that a default hitobject and control point causes . /// diff --git a/osu.Game.Tests/ImportTest.cs b/osu.Game.Tests/ImportTest.cs index b1e2730703d7..4272e34d0744 100644 --- a/osu.Game.Tests/ImportTest.cs +++ b/osu.Game.Tests/ImportTest.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Platform; @@ -42,7 +43,7 @@ private void waitForOrAssert(Func result, string failureMessage, int timeo while (!result()) Thread.Sleep(200); }); - Assert.IsTrue(task.Wait(timeout), failureMessage); + ClassicAssert.True(task.Wait(timeout), failureMessage); } public partial class TestOsuGameBase : OsuGameBase diff --git a/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs b/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs index 9926acf77213..0a4a4e50a778 100644 --- a/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs +++ b/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; namespace osu.Game.Tests.Localisation @@ -21,8 +22,8 @@ public void TestRomanisation() }; var romanisableString = metadata.GetDisplayTitleRomanisable(); - Assert.AreEqual(metadata.ToString(), romanisableString.Romanised); - Assert.AreEqual($"{metadata.ArtistUnicode} - {metadata.TitleUnicode}", romanisableString.Original); + ClassicAssert.AreEqual(metadata.ToString(), romanisableString.Romanised); + ClassicAssert.AreEqual($"{metadata.ArtistUnicode} - {metadata.TitleUnicode}", romanisableString.Original); } [Test] @@ -35,7 +36,7 @@ public void TestRomanisationNoUnicode() }; var romanisableString = metadata.GetDisplayTitleRomanisable(); - Assert.AreEqual(romanisableString.Romanised, romanisableString.Original); + ClassicAssert.AreEqual(romanisableString.Romanised, romanisableString.Original); } } } diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 6ec4e799e62d..b05b4c00b95c 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -6,6 +6,7 @@ using System.Linq; using Moq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Localisation; using osu.Game.Online.Rooms; @@ -196,7 +197,7 @@ public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid) Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); if (isValid) - Assert.IsNull(invalid); + ClassicAssert.Null(invalid); else Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } @@ -214,21 +215,21 @@ public void TestModBelongsToRuleset() [Test] public void TestFormatScoreMultiplier() { - Assert.AreEqual(ModUtils.FormatScoreMultiplier(0.9999).ToString(), "0.99x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.0).ToString(), "1.00x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.0001).ToString(), "1.01x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(0.9999).ToString(), "0.99x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(1.0).ToString(), "1.00x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(1.0001).ToString(), "1.01x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(0.899999999999999).ToString(), "0.90x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(0.9).ToString(), "0.90x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(0.900000000000001).ToString(), "0.90x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(0.899999999999999).ToString(), "0.90x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(0.9).ToString(), "0.90x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(0.900000000000001).ToString(), "0.90x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.099999999999999).ToString(), "1.10x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.1).ToString(), "1.10x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.100000000000001).ToString(), "1.10x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(1.099999999999999).ToString(), "1.10x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(1.1).ToString(), "1.10x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(1.100000000000001).ToString(), "1.10x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.045).ToString(), "1.05x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.05).ToString(), "1.05x"); - Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.055).ToString(), "1.06x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(1.045).ToString(), "1.05x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(1.05).ToString(), "1.05x"); + ClassicAssert.AreEqual(ModUtils.FormatScoreMultiplier(1.055).ToString(), "1.06x"); } private static readonly object[] multiplayer_mod_test_scenarios = @@ -309,7 +310,7 @@ public void TestMultiplayerModScenarios(MultiplayerTestScenario scenario) Assert.That(isValid, Is.EqualTo(scenario.InvalidTypes.Length == 0)); if (isValid) - Assert.IsNull(invalidMods); + ClassicAssert.Null(invalidMods); else Assert.That(invalidMods?.Select(t => t.GetType()), Is.EquivalentTo(scenario.InvalidTypes)); } @@ -318,12 +319,12 @@ public void TestMultiplayerModScenarios(MultiplayerTestScenario scenario) public void TestPlaylistsModScenarios() { // The rest are tested by TestMultiplayerModScenarios. - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), false, MatchType.Playlists, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), true, MatchType.Playlists, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), false, MatchType.Playlists, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), true, MatchType.Playlists, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), false, MatchType.Playlists, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), true, MatchType.Playlists, false)); + ClassicAssert.True(ModUtils.IsValidModForMatch(new OsuModHardRock(), false, MatchType.Playlists, false)); + ClassicAssert.True(ModUtils.IsValidModForMatch(new OsuModHardRock(), true, MatchType.Playlists, false)); + ClassicAssert.True(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), false, MatchType.Playlists, false)); + ClassicAssert.True(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), true, MatchType.Playlists, false)); + ClassicAssert.True(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), false, MatchType.Playlists, false)); + ClassicAssert.True(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), true, MatchType.Playlists, false)); } [Test] diff --git a/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs b/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs index 0f5c13ca0ed9..e3be681a918b 100644 --- a/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs +++ b/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -54,13 +55,13 @@ public void TestRoundingErrorCompensation() // every seventh bar's start time should be at least greater than the whole number we expect. // It cannot be less, as that can affect overlapping scroll algorithms // (the previous timing point might be chosen incorrectly if this is not the case) - Assert.GreaterOrEqual(barLine.StartTime, expectedTime); + ClassicAssert.GreaterOrEqual(barLine.StartTime, expectedTime); // on the other side, make sure we don't stray too far from the expected time either. - Assert.IsTrue(Precision.AlmostEquals(barLine.StartTime, expectedTime)); + ClassicAssert.True(Precision.AlmostEquals(barLine.StartTime, expectedTime)); // check major/minor lines for good measure too - Assert.AreEqual(i % signature.Numerator == 0, barLine.Major); + ClassicAssert.AreEqual(i % signature.Numerator == 0, barLine.Major); } } diff --git a/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs index a229331ef093..574d07ec02d8 100644 --- a/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs +++ b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs @@ -6,6 +6,7 @@ using System; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Models; @@ -22,8 +23,8 @@ public void TestOnlineWithOnline() var ourInfo = new BeatmapSetInfo { OnlineID = 123 }; var otherInfo = new BeatmapSetInfo { OnlineID = 123 }; - Assert.AreNotEqual(ourInfo, otherInfo); - Assert.IsTrue(ourInfo.MatchesOnlineID(otherInfo)); + ClassicAssert.AreNotEqual(ourInfo, otherInfo); + ClassicAssert.True(ourInfo.MatchesOnlineID(otherInfo)); } [Test] @@ -32,8 +33,8 @@ public void TestAudioEqualityNoFile() var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1); var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1); - Assert.AreNotEqual(beatmapSetA, beatmapSetB); - Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + ClassicAssert.AreNotEqual(beatmapSetA, beatmapSetB); + ClassicAssert.True(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); } [Test] @@ -49,8 +50,8 @@ public void TestAudioEqualityCaseSensitivity() addAudioFile(beatmapSetA, "abc", "AuDiO.mP3"); addAudioFile(beatmapSetB, "abc", "audio.mp3"); - Assert.AreNotEqual(beatmapSetA, beatmapSetB); - Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + ClassicAssert.AreNotEqual(beatmapSetA, beatmapSetB); + ClassicAssert.True(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); } [Test] @@ -62,8 +63,8 @@ public void TestAudioEqualitySameHash() addAudioFile(beatmapSetA, "abc"); addAudioFile(beatmapSetB, "abc"); - Assert.AreNotEqual(beatmapSetA, beatmapSetB); - Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + ClassicAssert.AreNotEqual(beatmapSetA, beatmapSetB); + ClassicAssert.True(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); } [Test] @@ -75,8 +76,8 @@ public void TestAudioEqualityDifferentHash() addAudioFile(beatmapSetA); addAudioFile(beatmapSetB); - Assert.AreNotEqual(beatmapSetA, beatmapSetB); - Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + ClassicAssert.AreNotEqual(beatmapSetA, beatmapSetB); + ClassicAssert.True(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); } [Test] @@ -89,8 +90,8 @@ public void TestAudioEqualityBeatmapInfoSameHash() var beatmap1 = beatmapSet.Beatmaps.First(); var beatmap2 = beatmapSet.Beatmaps.Last(); - Assert.AreNotEqual(beatmap1, beatmap2); - Assert.IsTrue(beatmap1.AudioEquals(beatmap2)); + ClassicAssert.AreNotEqual(beatmap1, beatmap2); + ClassicAssert.True(beatmap1.AudioEquals(beatmap2)); } [Test] @@ -107,12 +108,12 @@ public void TestAudioEqualityBeatmapInfoDifferentHash() var beatmap1 = beatmapSet.Beatmaps.First(); var beatmap2 = beatmapSet.Beatmaps.Last(); - Assert.AreNotEqual(beatmap1, beatmap2); + ClassicAssert.AreNotEqual(beatmap1, beatmap2); beatmap1.Metadata.AudioFile = filename1; beatmap2.Metadata.AudioFile = filename2; - Assert.IsFalse(beatmap1.AudioEquals(beatmap2)); + ClassicAssert.False(beatmap1.AudioEquals(beatmap2)); } private static void addAudioFile(BeatmapSetInfo beatmapSetInfo, string hash = null, string filename = null) @@ -128,7 +129,7 @@ public void TestDatabasedWithDatabased() var ourInfo = new BeatmapSetInfo { ID = guid }; var otherInfo = new BeatmapSetInfo { ID = guid }; - Assert.AreEqual(ourInfo, otherInfo); + ClassicAssert.AreEqual(ourInfo, otherInfo); } [Test] @@ -137,8 +138,8 @@ public void TestDatabasedWithOnline() var ourInfo = new BeatmapSetInfo { ID = Guid.NewGuid(), OnlineID = 12 }; var otherInfo = new BeatmapSetInfo { OnlineID = 12 }; - Assert.AreNotEqual(ourInfo, otherInfo); - Assert.IsTrue(ourInfo.MatchesOnlineID(otherInfo)); + ClassicAssert.AreNotEqual(ourInfo, otherInfo); + ClassicAssert.True(ourInfo.MatchesOnlineID(otherInfo)); } [Test] @@ -147,7 +148,7 @@ public void TestCheckNullID() var ourInfo = new BeatmapSetInfo { Hash = "1" }; var otherInfo = new BeatmapSetInfo { Hash = "2" }; - Assert.AreNotEqual(ourInfo, otherInfo); + ClassicAssert.AreNotEqual(ourInfo, otherInfo); } } } diff --git a/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs b/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs index 8a53759323f2..0da3041887ff 100644 --- a/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs +++ b/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects; @@ -66,6 +67,18 @@ public void TestApproximateDivisors() assertClosestDivisors(divisors, closestDivisors, cpi); } + [Test] + public void TestNegativeTimingPointOffset() + { + var cpi = new ControlPointInfo(); + cpi.Add(-300000, 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 static void assertClosestDivisors(IReadOnlyList divisors, IReadOnlyList closestDivisors, ControlPointInfo cpi, double step = 1) { List hitobjects = new List(); @@ -85,7 +98,7 @@ private static void assertClosestDivisors(IReadOnlyList divisors, IReadO }; for (int i = 0; i < divisors.Count; ++i) - Assert.AreEqual(closestDivisors[i], beatmap.ControlPointInfo.GetClosestBeatDivisor(beatmap.HitObjects[i].StartTime), $"at index {i}"); + ClassicAssert.AreEqual(closestDivisors[i], beatmap.ControlPointInfo.GetClosestBeatDivisor(beatmap.HitObjects[i].StartTime), $"at index {i}"); } } } diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 6b1b883ce7a9..f95e5768b0ed 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; @@ -141,7 +142,7 @@ public void TestIncompatibleWithSameInstanceViaMultiMod() private void assertCombinations(Type[][] expectedCombinations, Mod[] actualCombinations) { - Assert.AreEqual(expectedCombinations.Length, actualCombinations.Length); + ClassicAssert.AreEqual(expectedCombinations.Length, actualCombinations.Length); Assert.Multiple(() => { @@ -222,17 +223,17 @@ public TestLegacyDifficultyCalculator(params Mod[] mods) protected override Mod[] DifficultyAdjustmentMods { get; } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { throw new NotImplementedException(); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { throw new NotImplementedException(); } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { throw new NotImplementedException(); } diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 12aab055adf2..eb75f75deec3 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -5,13 +5,13 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Filter; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; namespace osu.Game.Tests.NonVisual.Filtering @@ -62,7 +62,7 @@ public void TestCriteriaMatchingNoRuleset() var criteria = new FilterCriteria(); var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.IsFalse(carouselItem.Filtered.Value); + ClassicAssert.False(carouselItem.Filtered.Value); } [Test] @@ -75,7 +75,7 @@ public void TestCriteriaMatchingSpecificRuleset() }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.IsTrue(carouselItem.Filtered.Value); + ClassicAssert.True(carouselItem.Filtered.Value); } [Test] @@ -89,7 +89,7 @@ public void TestCriteriaMatchingConvertedBeatmaps() }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.IsFalse(carouselItem.Filtered.Value); + ClassicAssert.False(carouselItem.Filtered.Value); } [Test] @@ -103,7 +103,7 @@ public void TestCriteriaMatchingConvertedBeatmapsForCustomRulesets() }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.IsFalse(carouselItem.Filtered.Value); + ClassicAssert.False(carouselItem.Filtered.Value); } [Test] @@ -124,7 +124,7 @@ public void TestCriteriaMatchingRangeMin(bool inclusive) }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.AreEqual(!inclusive, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(!inclusive, carouselItem.Filtered.Value); } [Test] @@ -145,7 +145,7 @@ public void TestCriteriaMatchingRangeMax(bool inclusive) }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.AreEqual(!inclusive, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(!inclusive, carouselItem.Filtered.Value); } [Test] @@ -167,7 +167,7 @@ public void TestCriteriaMatchingTerms(string terms, bool filtered) }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.AreEqual(filtered, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value); } [Test] @@ -196,7 +196,7 @@ public void TestCriteriaMatchingExactTerms(string terms, bool filtered) }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.AreEqual(filtered, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value); } [Test] @@ -216,7 +216,7 @@ public void TestCriteriaMatchingCreator(string creatorName, bool filtered) }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.AreEqual(filtered, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value); } [Test] @@ -237,7 +237,7 @@ public void TestCriteriaMatchingTitle(string titleName, bool filtered) }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.AreEqual(filtered, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value); } [Test] @@ -261,7 +261,7 @@ public void TestCriteriaMatchingArtist(string artistName, bool filtered) }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.AreEqual(filtered, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value); } [Test] @@ -279,7 +279,7 @@ public void TestCriteriaMatchingArtistWithNullUnicodeName(string artistName, boo }; var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); carouselItem.Filter(criteria); - Assert.AreEqual(filtered, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value); } [TestCase("202010", true)] @@ -296,7 +296,7 @@ public void TestCriteriaMatchingBeatmapIDs(string query, bool filtered) var carouselItem = new CarouselBeatmap(beatmap); carouselItem.Filter(criteria); - Assert.AreEqual(filtered, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value); } [Test] @@ -313,7 +313,7 @@ public void TestCriteriaNotMatchingArtist(string excludedTerm) var carouselItem = new CarouselBeatmap(beatmap); carouselItem.Filter(criteria); - Assert.True(carouselItem.Filtered.Value); + ClassicAssert.True(carouselItem.Filtered.Value); } [TestCase("simple", false)] @@ -328,7 +328,7 @@ public void TestCriteriaMatchingUserTags(string query, bool filtered) var carouselItem = new CarouselBeatmap(beatmap); carouselItem.Filter(criteria); - Assert.AreEqual(filtered, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value); } [Test] @@ -346,7 +346,7 @@ public void TestCriteriaMatchingMultipleTagsAtOnce() var carouselItem = new CarouselBeatmap(beatmap); carouselItem.Filter(criteria); - Assert.AreEqual(false, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(false, carouselItem.Filtered.Value); } [Test] @@ -364,7 +364,7 @@ public void TestCriteriaAllTagFiltersMustMatch() var carouselItem = new CarouselBeatmap(beatmap); carouselItem.Filter(criteria); - Assert.AreEqual(true, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(true, carouselItem.Filtered.Value); } [Test] @@ -381,7 +381,7 @@ public void TestCriteriaMatchingTagExcluded() var carouselItem = new CarouselBeatmap(beatmap); carouselItem.Filter(criteria); - Assert.AreEqual(true, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(true, carouselItem.Filtered.Value); } [Test] @@ -399,7 +399,7 @@ public void TestCriteriaOneTagIncludedAndOneTagExcluded() var carouselItem = new CarouselBeatmap(beatmap); carouselItem.Filter(criteria); - Assert.AreEqual(true, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(true, carouselItem.Filtered.Value); } [Test] @@ -411,7 +411,7 @@ public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive() carouselItem.BeatmapInfo.Metadata.UserTags.Clear(); carouselItem.Filter(criteria); - Assert.True(carouselItem.Filtered.Value); + ClassicAssert.True(carouselItem.Filtered.Value); } [Test] @@ -424,7 +424,7 @@ public void TestCustomRulesetCriteria([Values(null, true, false)] bool? matchCus var carouselItem = new CarouselBeatmap(beatmap); carouselItem.Filter(criteria); - Assert.AreEqual(matchCustomCriteria == false, carouselItem.Filtered.Value); + ClassicAssert.AreEqual(matchCustomCriteria == false, carouselItem.Filtered.Value); } [TestCase("title!=Title", new[] { 2, 4, 6 })] @@ -588,6 +588,26 @@ public void TestNotEqualSearchForDateFilter(string query, int[] expectedBeatmapI Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); } + // This is a temporary class that emulates what these tests originally used from song select v1. + // If anyone ever ends up tidying up these test, here's a starting point: + // https://gist.github.com/peppy/67fda38f6483fd1dd01ef845ed5bf932 + public class CarouselBeatmap + { + public readonly BeatmapInfo BeatmapInfo; + + public BindableBool Filtered = new BindableBool(); + + public CarouselBeatmap(BeatmapInfo beatmapInfo) + { + BeatmapInfo = beatmapInfo; + } + + public void Filter(FilterCriteria criteria) + { + Filtered.Value = !BeatmapCarouselFilterMatching.CheckCriteriaMatch(BeatmapInfo, criteria); + } + } + private class CustomCriteria : IRulesetFilterCriteria { private readonly bool match; diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 8bef6b04a7a7..09f85a7defa6 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -5,12 +5,12 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Rulesets.Filter; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; namespace osu.Game.Tests.NonVisual.Filtering @@ -24,8 +24,8 @@ public void TestApplyQueriesBareWords() const string query = "looking for a beatmap"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("looking for a beatmap", filterCriteria.SearchText); - Assert.AreEqual(4, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("looking for a beatmap", filterCriteria.SearchText); + ClassicAssert.AreEqual(4, filterCriteria.SearchTerms.Length); } [Test] @@ -34,8 +34,8 @@ public void TestApplyQueriesBareWordsWithExactMatch() const string query = "looking for \"a beatmap\"! like \"this\""; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("looking for \"a beatmap\"! like \"this\"", filterCriteria.SearchText); - Assert.AreEqual(5, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("looking for \"a beatmap\"! like \"this\"", filterCriteria.SearchText); + ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length); Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("a beatmap")); Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase)); @@ -59,8 +59,8 @@ public void TestApplyFullPhraseQueryWithExclamationPointInTerm() const string query = "looking for \"circles!\"!"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("looking for \"circles!\"!", filterCriteria.SearchText); - Assert.AreEqual(3, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("looking for \"circles!\"!", filterCriteria.SearchText); + ClassicAssert.AreEqual(3, filterCriteria.SearchTerms.Length); Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("circles!")); Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase)); @@ -78,8 +78,8 @@ public void TestApplyBrokenFullPhraseQuery() const string query = "\"!"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("\"!", filterCriteria.SearchText); - Assert.AreEqual(1, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("\"!", filterCriteria.SearchText); + ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length); Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("!")); Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase)); @@ -92,11 +92,11 @@ public void TestApplyStarQueries(string variant) string query = $"{variant}<4 easy"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("easy", filterCriteria.SearchText.Trim()); - Assert.AreEqual(1, filterCriteria.SearchTerms.Length); - Assert.IsNotNull(filterCriteria.StarDifficulty.Max); - Assert.AreEqual(filterCriteria.StarDifficulty.Max, 4.00d); - Assert.IsNull(filterCriteria.StarDifficulty.Min); + ClassicAssert.AreEqual("easy", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length); + ClassicAssert.NotNull(filterCriteria.StarDifficulty.Max); + ClassicAssert.AreEqual(filterCriteria.StarDifficulty.Max!, 4.00d); + ClassicAssert.Null(filterCriteria.StarDifficulty.Min); } [Test] @@ -105,9 +105,9 @@ public void TestStarQueriesInclusive() const string query = "stars>=6"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(filterCriteria.StarDifficulty.Min, 6.00d); - Assert.True(filterCriteria.StarDifficulty.IsLowerInclusive); - Assert.IsNull(filterCriteria.StarDifficulty.Max); + ClassicAssert.AreEqual(filterCriteria.StarDifficulty.Min!, 6.00d); + ClassicAssert.True(filterCriteria.StarDifficulty.IsLowerInclusive); + ClassicAssert.Null(filterCriteria.StarDifficulty.Max); } /* @@ -126,12 +126,12 @@ public void TestApplyApproachRateQueries() const string query = "ar>=9 difficult"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("difficult", filterCriteria.SearchText.Trim()); - Assert.AreEqual(1, filterCriteria.SearchTerms.Length); - Assert.IsNotNull(filterCriteria.ApproachRate.Min); - Assert.Greater(filterCriteria.ApproachRate.Min, 8.9f); - Assert.Less(filterCriteria.ApproachRate.Min, 9.0f); - Assert.IsNull(filterCriteria.ApproachRate.Max); + ClassicAssert.AreEqual("difficult", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length); + ClassicAssert.NotNull(filterCriteria.ApproachRate.Min); + ClassicAssert.Greater(filterCriteria.ApproachRate.Min!, 8.9f); + ClassicAssert.Less(filterCriteria.ApproachRate.Min!, 9.0f); + ClassicAssert.Null(filterCriteria.ApproachRate.Max); } [Test] @@ -140,12 +140,12 @@ public void TestApplyDrainRateQueriesByDrKeyword() const string query = "dr>2 quite specific dr<:6"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("quite specific", filterCriteria.SearchText.Trim()); - Assert.AreEqual(2, filterCriteria.SearchTerms.Length); - Assert.Greater(filterCriteria.DrainRate.Min, 2.0f); - Assert.Less(filterCriteria.DrainRate.Min, 2.1f); - Assert.Greater(filterCriteria.DrainRate.Max, 6.0f); - Assert.Less(filterCriteria.DrainRate.Min, 6.1f); + ClassicAssert.AreEqual("quite specific", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(2, filterCriteria.SearchTerms.Length); + ClassicAssert.Greater(filterCriteria.DrainRate.Min!, 2.0f); + ClassicAssert.Less(filterCriteria.DrainRate.Min!, 2.1f); + ClassicAssert.Greater(filterCriteria.DrainRate.Max!, 6.0f); + ClassicAssert.Less(filterCriteria.DrainRate.Min!, 6.1f); } [Test] @@ -154,12 +154,12 @@ public void TestApplyDrainRateQueriesByHpKeyword() const string query = "hp>2 quite specific hp<=6"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("quite specific", filterCriteria.SearchText.Trim()); - Assert.AreEqual(2, filterCriteria.SearchTerms.Length); - Assert.Greater(filterCriteria.DrainRate.Min, 2.0f); - Assert.Less(filterCriteria.DrainRate.Min, 2.1f); - Assert.Greater(filterCriteria.DrainRate.Max, 6.0f); - Assert.Less(filterCriteria.DrainRate.Min, 6.1f); + ClassicAssert.AreEqual("quite specific", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(2, filterCriteria.SearchTerms.Length); + ClassicAssert.Greater(filterCriteria.DrainRate.Min!, 2.0f); + ClassicAssert.Less(filterCriteria.DrainRate.Min!, 2.1f); + ClassicAssert.Greater(filterCriteria.DrainRate.Max!, 6.0f); + ClassicAssert.Less(filterCriteria.DrainRate.Min!, 6.1f); } [Test] @@ -168,12 +168,12 @@ public void TestApplyOverallDifficultyQueries() const string query = "od>4 easy od<8"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("easy", filterCriteria.SearchText.Trim()); - Assert.AreEqual(1, filterCriteria.SearchTerms.Length); - Assert.Greater(filterCriteria.OverallDifficulty.Min, 4.0); - Assert.Less(filterCriteria.OverallDifficulty.Min, 4.1); - Assert.Greater(filterCriteria.OverallDifficulty.Max, 7.9); - Assert.Less(filterCriteria.OverallDifficulty.Max, 8.0); + ClassicAssert.AreEqual("easy", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length); + ClassicAssert.Greater(filterCriteria.OverallDifficulty.Min!, 4.0); + ClassicAssert.Less(filterCriteria.OverallDifficulty.Min!, 4.1); + ClassicAssert.Greater(filterCriteria.OverallDifficulty.Max!, 7.9); + ClassicAssert.Less(filterCriteria.OverallDifficulty.Max!, 8.0); } [Test] @@ -182,8 +182,8 @@ public void TestApplyBPMQueries() const string query = "bpm=200"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(filterCriteria.BPM.Min, 199.5d); - Assert.AreEqual(filterCriteria.BPM.Max, 200.5d); + ClassicAssert.AreEqual(filterCriteria.BPM.Min!, 199.5d); + ClassicAssert.AreEqual(filterCriteria.BPM.Max!, 200.5d); } [Test] @@ -192,11 +192,11 @@ public void TestApplyBPMRangeQueries() const string query = "bpm>:200 gotta go fast"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("gotta go fast", filterCriteria.SearchText.Trim()); - Assert.AreEqual(3, filterCriteria.SearchTerms.Length); - Assert.IsNotNull(filterCriteria.BPM.Min); - Assert.AreEqual(filterCriteria.BPM.Min, 199.5d); - Assert.IsNull(filterCriteria.BPM.Max); + ClassicAssert.AreEqual("gotta go fast", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(3, filterCriteria.SearchTerms.Length); + ClassicAssert.NotNull(filterCriteria.BPM.Min); + ClassicAssert.AreEqual(filterCriteria.BPM.Min!, 199.5d); + ClassicAssert.Null(filterCriteria.BPM.Max); } private static readonly object[] correct_length_query_examples = @@ -229,10 +229,10 @@ public void TestApplyLengthQueries(string lengthQuery, TimeSpan expectedLength, string query = $"length={lengthQuery} time"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("time", filterCriteria.SearchText.Trim()); - Assert.AreEqual(1, filterCriteria.SearchTerms.Length); - Assert.AreEqual(expectedLength.TotalMilliseconds - scale.TotalMilliseconds / 2.0, filterCriteria.Length.Min); - Assert.AreEqual(expectedLength.TotalMilliseconds + scale.TotalMilliseconds / 2.0, filterCriteria.Length.Max); + ClassicAssert.AreEqual("time", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual(expectedLength.TotalMilliseconds - scale.TotalMilliseconds / 2.0, filterCriteria.Length.Min); + ClassicAssert.AreEqual(expectedLength.TotalMilliseconds + scale.TotalMilliseconds / 2.0, filterCriteria.Length.Max); } private static readonly object[] incorrect_length_query_examples = @@ -255,7 +255,7 @@ public void TestInvalidLengthQueries(string lengthQuery) string query = $"length={lengthQuery} time"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(false, filterCriteria.Length.HasFilter); + ClassicAssert.AreEqual(false, filterCriteria.Length.HasFilter); } [Test] @@ -264,12 +264,12 @@ public void TestApplyDivisorQueries() const string query = "that's a time signature alright! divisor:12"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("that's a time signature alright!", filterCriteria.SearchText.Trim()); - Assert.AreEqual(5, filterCriteria.SearchTerms.Length); - Assert.AreEqual(12, filterCriteria.BeatDivisor.Min); - Assert.IsTrue(filterCriteria.BeatDivisor.IsLowerInclusive); - Assert.AreEqual(12, filterCriteria.BeatDivisor.Max); - Assert.IsTrue(filterCriteria.BeatDivisor.IsUpperInclusive); + ClassicAssert.AreEqual("that's a time signature alright!", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual(12, filterCriteria.BeatDivisor.Min); + ClassicAssert.True(filterCriteria.BeatDivisor.IsLowerInclusive); + ClassicAssert.AreEqual(12, filterCriteria.BeatDivisor.Max); + ClassicAssert.True(filterCriteria.BeatDivisor.IsUpperInclusive); } [Test] @@ -278,7 +278,7 @@ public void TestPartialStatusMatch() const string query = "status=r"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.IsNotEmpty(filterCriteria.OnlineStatus.Values); + ClassicAssert.IsNotEmpty(filterCriteria.OnlineStatus.Values); Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Ranked)); } @@ -288,9 +288,9 @@ public void TestApplyStatusQueries() const string query = "I want the pp status=ranked"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("I want the pp", filterCriteria.SearchText.Trim()); - Assert.AreEqual(4, filterCriteria.SearchTerms.Length); - Assert.IsNotEmpty(filterCriteria.OnlineStatus.Values); + ClassicAssert.AreEqual("I want the pp", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(4, filterCriteria.SearchTerms.Length); + ClassicAssert.IsNotEmpty(filterCriteria.OnlineStatus.Values); Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Ranked)); } @@ -309,7 +309,7 @@ public void TestPartialStatusNotMatch() const string query = "status!=r"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.IsNotEmpty(filterCriteria.OnlineStatus.Values); + ClassicAssert.IsNotEmpty(filterCriteria.OnlineStatus.Values); Assert.That(filterCriteria.OnlineStatus.Values, Does.Not.Contain(BeatmapOnlineStatus.Ranked)); } @@ -375,9 +375,9 @@ public void TestApplyCreatorQueries(string keyword) string query = $"beatmap specifically by {keyword}=my_fav"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("beatmap specifically by", filterCriteria.SearchText.Trim()); - Assert.AreEqual(3, filterCriteria.SearchTerms.Length); - Assert.AreEqual("my_fav", filterCriteria.Creator.SearchTerm); + ClassicAssert.AreEqual("beatmap specifically by", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(3, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("my_fav", filterCriteria.Creator.SearchTerm); } [Test] @@ -386,9 +386,9 @@ public void TestApplyTitleQueries() const string query = "find me songs with title=\"a certain title\" please"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("find me songs with please", filterCriteria.SearchText.Trim()); - Assert.AreEqual(5, filterCriteria.SearchTerms.Length); - Assert.AreEqual("a certain title", filterCriteria.Title.SearchTerm); + ClassicAssert.AreEqual("find me songs with please", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("a certain title", filterCriteria.Title.SearchTerm); Assert.That(filterCriteria.Title.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase)); } @@ -398,9 +398,9 @@ public void TestApplyArtistQueries() const string query = "find me songs by artist=singer please"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("find me songs by please", filterCriteria.SearchText.Trim()); - Assert.AreEqual(5, filterCriteria.SearchTerms.Length); - Assert.AreEqual("singer", filterCriteria.Artist.SearchTerm); + ClassicAssert.AreEqual("find me songs by please", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("singer", filterCriteria.Artist.SearchTerm); Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring)); } @@ -410,9 +410,9 @@ public void TestApplyArtistQueriesWithSpaces() const string query = "really like artist=\"name with space\" yes"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("really like yes", filterCriteria.SearchText.Trim()); - Assert.AreEqual(3, filterCriteria.SearchTerms.Length); - Assert.AreEqual("name with space", filterCriteria.Artist.SearchTerm); + ClassicAssert.AreEqual("really like yes", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(3, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("name with space", filterCriteria.Artist.SearchTerm); Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase)); } @@ -423,8 +423,8 @@ public void TestApplyArtistQueriesWithSpacesFullPhrase() var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); Assert.That(filterCriteria.SearchText.Trim(), Is.Empty); - Assert.AreEqual(0, filterCriteria.SearchTerms.Length); - Assert.AreEqual("The Only One", filterCriteria.Artist.SearchTerm); + ClassicAssert.AreEqual(0, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("The Only One", filterCriteria.Artist.SearchTerm); Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase)); } @@ -434,9 +434,9 @@ public void TestApplyArtistQueriesOneDoubleQuote() const string query = "weird artist=double\"quote"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("weird", filterCriteria.SearchText.Trim()); - Assert.AreEqual(1, filterCriteria.SearchTerms.Length); - Assert.AreEqual("double\"quote", filterCriteria.Artist.SearchTerm); + ClassicAssert.AreEqual("weird", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("double\"quote", filterCriteria.Artist.SearchTerm); } [Test] @@ -445,7 +445,7 @@ public void TestOperatorParsing() const string query = "artist=> new CarouselBeatmap(new BeatmapInfo + }).Select(info => new FilterMatchingTest.CarouselBeatmap(new BeatmapInfo { Metadata = new BeatmapMetadata { @@ -536,9 +536,9 @@ public void TestApplySourceQueries() const string query = "find me songs with source=\"unit tests\" please"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual("find me songs with please", filterCriteria.SearchText.Trim()); - Assert.AreEqual(5, filterCriteria.SearchTerms.Length); - Assert.AreEqual("unit tests", filterCriteria.Source.SearchTerm); + ClassicAssert.AreEqual("find me songs with please", filterCriteria.SearchText.Trim()); + ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length); + ClassicAssert.AreEqual("unit tests", filterCriteria.Source.SearchTerm); Assert.That(filterCriteria.Source.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase)); } @@ -581,7 +581,7 @@ public void TestValidDateQueries(string dateQuery) string query = $"lastplayed<{dateQuery} time"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); + ClassicAssert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); } private static readonly object[] incorrect_date_query_examples = @@ -606,7 +606,7 @@ public void TestInvalidDateQueries(string dateQuery) string query = $"played<{dateQuery} time"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(false, filterCriteria.LastPlayed.HasFilter); + ClassicAssert.AreEqual(false, filterCriteria.LastPlayed.HasFilter); } [Test] @@ -615,11 +615,11 @@ public void TestGreaterDateQuery() const string query = "lastplayed>50"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.That(filterCriteria.LastPlayed.Max, Is.Not.Null); - Assert.That(filterCriteria.LastPlayed.Min, Is.Null); + Assert.That(filterCriteria.LastPlayed.Max!, Is.Not.Null); + Assert.That(filterCriteria.LastPlayed.Min!, Is.Null); // the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance // (irrelevant in proportion to the actual filter proscribed). - Assert.That(filterCriteria.LastPlayed.Max, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5))); + Assert.That(filterCriteria.LastPlayed.Max!, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5))); } [Test] @@ -628,11 +628,11 @@ public void TestLowerDateQuery() const string query = "lastplayed<50"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.That(filterCriteria.LastPlayed.Max, Is.Null); - Assert.That(filterCriteria.LastPlayed.Min, Is.Not.Null); + Assert.That(filterCriteria.LastPlayed.Max!, Is.Null); + Assert.That(filterCriteria.LastPlayed.Min!, Is.Not.Null); // the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance // (irrelevant in proportion to the actual filter proscribed). - Assert.That(filterCriteria.LastPlayed.Min, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5))); + Assert.That(filterCriteria.LastPlayed.Min!, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5))); } [Test] @@ -641,12 +641,12 @@ public void TestBothSidesDateQuery() const string query = "lastplayed>3M lastplayed<1y6M"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.That(filterCriteria.LastPlayed.Min, Is.Not.Null); - Assert.That(filterCriteria.LastPlayed.Max, Is.Not.Null); + Assert.That(filterCriteria.LastPlayed.Min!, Is.Not.Null); + Assert.That(filterCriteria.LastPlayed.Max!, Is.Not.Null); // the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance // (irrelevant in proportion to the actual filter proscribed). - Assert.That(filterCriteria.LastPlayed.Min, Is.EqualTo(DateTimeOffset.Now.AddMonths(-6).AddYears(-1)).Within(TimeSpan.FromSeconds(5))); - Assert.That(filterCriteria.LastPlayed.Max, Is.EqualTo(DateTimeOffset.Now.AddMonths(-3)).Within(TimeSpan.FromSeconds(5))); + Assert.That(filterCriteria.LastPlayed.Min!, Is.EqualTo(DateTimeOffset.Now.AddMonths(-6).AddYears(-1)).Within(TimeSpan.FromSeconds(5))); + Assert.That(filterCriteria.LastPlayed.Max!, Is.EqualTo(DateTimeOffset.Now.AddMonths(-3)).Within(TimeSpan.FromSeconds(5))); } [Test] @@ -655,7 +655,7 @@ public void TestEqualDateQuery() const string query = "lastplayed=50"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(false, filterCriteria.LastPlayed.HasFilter); + ClassicAssert.AreEqual(false, filterCriteria.LastPlayed.HasFilter); } [Test] @@ -664,8 +664,8 @@ public void TestOutOfRangeDateQuery() const string query = "lastplayed<10000y"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); - Assert.AreEqual(DateTimeOffset.MinValue.AddMilliseconds(1), filterCriteria.LastPlayed.Min); + ClassicAssert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); + ClassicAssert.AreEqual(DateTimeOffset.MinValue.AddMilliseconds(1), filterCriteria.LastPlayed.Min); } private static DateTimeOffset dateTimeOffsetFromDateOnly(int year, int month, int day) => @@ -707,8 +707,8 @@ public void TestValidRankedDateQueries(string query, DateTimeOffset expected, Fu { var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(true, filterCriteria.DateRanked.HasFilter); - Assert.AreEqual(expected, f(filterCriteria)); + ClassicAssert.AreEqual(true, filterCriteria.DateRanked.HasFilter); + ClassicAssert.AreEqual(expected, f(filterCriteria)); } private static readonly object[] ranked_date_invalid_test_cases = @@ -724,7 +724,7 @@ public void TestInvalidRankedDateQueries(string query) { var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(false, filterCriteria.DateRanked.HasFilter); + ClassicAssert.AreEqual(false, filterCriteria.DateRanked.HasFilter); } private static readonly object[] submitted_date_test_cases = @@ -738,6 +738,16 @@ public void TestInvalidRankedDateQueries(string query) new object[] { "submitted=99999", false }, new object[] { "submitted>=2012-03-05-04", false }, new object[] { "submitted>=2012/03.05-04", false }, + + new object[] { "created<2012", true }, + new object[] { "created<2012.03", true }, + new object[] { "created<2012/03/05", true }, + new object[] { "created<2012-3-5", true }, + + new object[] { "created<0", false }, + new object[] { "created=99999", false }, + new object[] { "created>=2012-03-05-04", false }, + new object[] { "created>=2012/03.05-04", false }, }; [Test] @@ -746,7 +756,7 @@ public void TestInvalidRankedDateQueries(string query, bool expected) { var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); - Assert.AreEqual(expected, filterCriteria.DateSubmitted.HasFilter); + ClassicAssert.AreEqual(expected, filterCriteria.DateSubmitted.HasFilter); } private static readonly object[] played_query_tests = @@ -772,8 +782,8 @@ public void TestPlayedQuery(string query, DateTimeOffset reference, bool matched { var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, $"played={query}"); - Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); - Assert.AreEqual(matched, filterCriteria.LastPlayed.IsInRange(reference)); + ClassicAssert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); + ClassicAssert.AreEqual(matched, filterCriteria.LastPlayed.IsInRange(reference)); } [Test] diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs index 69c98351ad91..174d14d2268a 100644 --- a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs +++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Audio; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Judgements; @@ -37,7 +38,7 @@ public void TestResultIfOnlyParentHitWindowIsEmpty() testObject.AddNested(nested); testDrawableRuleset.HitObjects = new List { testObject }; - Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, nested.HitWindows); + ClassicAssert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, nested.HitWindows); } [Test] @@ -48,7 +49,7 @@ public void TestResultIfParentHitWindowsIsNotEmpty() testObject.AddNested(nested); testDrawableRuleset.HitObjects = new List { testObject }; - Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, testObject.HitWindows); + ClassicAssert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, testObject.HitWindows); } [Test] @@ -61,7 +62,7 @@ public void TestResultIfParentAndChildHitWindowsAreEmpty() var secondObject = new TestHitObject(new DefaultHitWindows()); testDrawableRuleset.HitObjects = new List { firstObject, secondObject }; - Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, secondObject.HitWindows); + ClassicAssert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, secondObject.HitWindows); } [Test] @@ -73,7 +74,7 @@ public void TestResultIfAllHitWindowsAreEmpty() testDrawableRuleset.HitObjects = new List { firstObject }; - Assert.IsNull(testDrawableRuleset.FirstAvailableHitWindows); + ClassicAssert.Null(testDrawableRuleset.FirstAvailableHitWindows); } [SuppressMessage("ReSharper", "UnassignedGetOnlyAutoProperty")] diff --git a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs index 0fcf754cf664..169639f1d3b3 100644 --- a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs +++ b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Utils; namespace osu.Game.Tests.NonVisual @@ -19,7 +20,7 @@ public class FormatUtilsTest [TestCase(1, "100.00%")] public void TestAccuracyFormatting(double input, string expectedOutput) { - Assert.AreEqual(expectedOutput, input.FormatAccuracy().ToString()); + ClassicAssert.AreEqual(expectedOutput, input.FormatAccuracy().ToString()); } [TestCase(3, "3.00")] @@ -32,7 +33,7 @@ public void TestAccuracyFormatting(double input, string expectedOutput) [TestCase(4, "4.00")] public void TestStarRatingFormatting(double input, string expectedOutput) { - Assert.AreEqual(expectedOutput, input.FormatStarRating().ToString()); + ClassicAssert.AreEqual(expectedOutput, input.FormatStarRating().ToString()); } } } diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs index ffb21f124c95..0bfca14c9a4c 100644 --- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs +++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Replays; using osu.Game.Rulesets.Replays; @@ -223,7 +224,7 @@ public void TestReplayStreaming() // no frames are arrived yet setTime(0, null); setTime(1000, null); - Assert.IsTrue(handler.WaitingForFrame, "Should be waiting for the first frame"); + ClassicAssert.True(handler.WaitingForFrame, "Should be waiting for the first frame"); replay.Frames.Add(new TestReplayFrame(0)); replay.Frames.Add(new TestReplayFrame(1000)); @@ -231,11 +232,11 @@ public void TestReplayStreaming() // should always play from beginning setTime(1000, 0); confirmCurrentFrame(0); - Assert.IsFalse(handler.WaitingForFrame, "Should not be waiting yet"); + ClassicAssert.False(handler.WaitingForFrame, "Should not be waiting yet"); setTime(1000, 1000); confirmCurrentFrame(1); confirmNextFrame(null); - Assert.IsTrue(handler.WaitingForFrame, "Should be waiting"); + ClassicAssert.True(handler.WaitingForFrame, "Should be waiting"); // cannot seek beyond the last frame setTime(1500, null); @@ -359,17 +360,17 @@ private void fastForwardToPoint(double destination) private void setTime(double set, double? expect) { - Assert.AreEqual(expect, handler.SetFrameFromTime(set), "Unexpected return value"); + ClassicAssert.AreEqual(expect, handler.SetFrameFromTime(set), "Unexpected return value"); } private void confirmCurrentFrame(int? frame) { - Assert.AreEqual(frame is int x ? replay.Frames[x].Time : null, handler.CurrentFrame?.Time, "Unexpected current frame"); + ClassicAssert.AreEqual(frame is int x ? replay.Frames[x].Time : null, handler.CurrentFrame?.Time, "Unexpected current frame"); } private void confirmNextFrame(int? frame) { - Assert.AreEqual(frame is int x ? replay.Frames[x].Time : null, handler.NextFrame?.Time, "Unexpected next frame"); + ClassicAssert.AreEqual(frame is int x ? replay.Frames[x].Time : null, handler.NextFrame?.Time, "Unexpected next frame"); } private class TestReplayFrame : ReplayFrame diff --git a/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs b/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs index 8809ce3adc60..a33a36f2d97d 100644 --- a/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs +++ b/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs @@ -5,6 +5,7 @@ using System; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Utils; namespace osu.Game.Tests.NonVisual @@ -25,7 +26,7 @@ public void SetUp() [Test] public void TestEmptyQueue() { - Assert.AreEqual(0, queue.Count); + ClassicAssert.AreEqual(0, queue.Count); Assert.Throws(() => _ = queue[0]); @@ -35,7 +36,7 @@ public void TestEmptyQueue() foreach (int _ in queue) count++; - Assert.AreEqual(0, count); + ClassicAssert.AreEqual(0, count); } [TestCase(1)] @@ -46,14 +47,14 @@ public void TestBelowCapacity(int count) for (int i = 0; i < count; ++i) queue.Enqueue(i); - Assert.AreEqual(count, queue.Count); + ClassicAssert.AreEqual(count, queue.Count); for (int i = 0; i < count; ++i) - Assert.AreEqual(i, queue[i]); + ClassicAssert.AreEqual(i, queue[i]); int j = 0; foreach (int item in queue) - Assert.AreEqual(j++, item); + ClassicAssert.AreEqual(j++, item); for (int i = queue.Count; i < queue.Count + capacity; i++) Assert.Throws(() => _ = queue[i]); @@ -67,14 +68,14 @@ public void TestEnqueueAtFullCapacity(int count) for (int i = 0; i < count; ++i) queue.Enqueue(i); - Assert.AreEqual(capacity, queue.Count); + ClassicAssert.AreEqual(capacity, queue.Count); for (int i = 0; i < queue.Count; ++i) - Assert.AreEqual(count - capacity + i, queue[i]); + ClassicAssert.AreEqual(count - capacity + i, queue[i]); int j = count - capacity; foreach (int item in queue) - Assert.AreEqual(j++, item); + ClassicAssert.AreEqual(j++, item); for (int i = queue.Count; i < queue.Count + capacity; i++) Assert.Throws(() => _ = queue[i]); @@ -90,8 +91,8 @@ public void TestDequeueAtFullCapacity(int count) for (int i = 0; i < capacity; ++i) { - Assert.AreEqual(count - capacity + i, queue.Dequeue()); - Assert.AreEqual(2 - i, queue.Count); + ClassicAssert.AreEqual(count - capacity + i, queue.Dequeue()); + ClassicAssert.AreEqual(2 - i, queue.Count); } Assert.Throws(() => queue.Dequeue()); @@ -102,20 +103,20 @@ public void TestClearQueue() { queue.Enqueue(3); queue.Enqueue(5); - Assert.AreEqual(2, queue.Count); + ClassicAssert.AreEqual(2, queue.Count); queue.Clear(); - Assert.AreEqual(0, queue.Count); + ClassicAssert.AreEqual(0, queue.Count); Assert.Throws(() => _ = queue[0]); queue.Enqueue(7); - Assert.AreEqual(1, queue.Count); - Assert.AreEqual(7, queue[0]); + ClassicAssert.AreEqual(1, queue.Count); + ClassicAssert.AreEqual(7, queue[0]); Assert.Throws(() => _ = queue[1]); queue.Enqueue(9); - Assert.AreEqual(2, queue.Count); - Assert.AreEqual(9, queue[1]); + ClassicAssert.AreEqual(2, queue.Count); + ClassicAssert.AreEqual(9, queue[1]); } } } diff --git a/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs b/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs index 664a499cc389..b502aa1782fa 100644 --- a/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs +++ b/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Utils; using osu.Game.Utils; @@ -28,9 +29,9 @@ public void TestCheckValueInsideSinglePeriod() var tracker = new PeriodTracker(single_period); var period = single_period.Single(); - Assert.IsTrue(tracker.IsInAny(period.Start)); - Assert.IsTrue(tracker.IsInAny(getMidpoint(period))); - Assert.IsTrue(tracker.IsInAny(period.End)); + ClassicAssert.True(tracker.IsInAny(period.Start)); + ClassicAssert.True(tracker.IsInAny(getMidpoint(period))); + ClassicAssert.True(tracker.IsInAny(period.End)); } [Test] @@ -39,7 +40,7 @@ public void TestCheckValuesInsidePeriods() var tracker = new PeriodTracker(unordered_periods); foreach (var period in unordered_periods) - Assert.IsTrue(tracker.IsInAny(getMidpoint(period))); + ClassicAssert.True(tracker.IsInAny(getMidpoint(period))); } [Test] @@ -48,7 +49,7 @@ public void TestCheckValuesInRandomOrder() var tracker = new PeriodTracker(unordered_periods); foreach (var period in unordered_periods.OrderBy(_ => RNG.Next())) - Assert.IsTrue(tracker.IsInAny(getMidpoint(period))); + ClassicAssert.True(tracker.IsInAny(getMidpoint(period))); } [Test] @@ -60,12 +61,12 @@ public void TestCheckValuesOutOfPeriods() new Period(3.0, 4.0) }); - Assert.IsFalse(tracker.IsInAny(0.9), "Time before first period is being considered inside"); + ClassicAssert.False(tracker.IsInAny(0.9), "Time before first period is being considered inside"); - Assert.IsFalse(tracker.IsInAny(2.1), "Time right after first period is being considered inside"); - Assert.IsFalse(tracker.IsInAny(2.9), "Time right before second period is being considered inside"); + ClassicAssert.False(tracker.IsInAny(2.1), "Time right after first period is being considered inside"); + ClassicAssert.False(tracker.IsInAny(2.9), "Time right before second period is being considered inside"); - Assert.IsFalse(tracker.IsInAny(4.1), "Time after last period is being considered inside"); + ClassicAssert.False(tracker.IsInAny(4.1), "Time after last period is being considered inside"); } [Test] diff --git a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs index 18ac5b496487..f68f743723d2 100644 --- a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs +++ b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs @@ -6,6 +6,7 @@ using System; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Utils; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -25,8 +26,8 @@ public void TestDistributedHits() var unstableRate = new UnstableRate(events); - Assert.IsNotNull(unstableRate.Value); - Assert.AreEqual(unstableRate.Value.Value, 10 * Math.Sqrt(10), Precision.DOUBLE_EPSILON); + Assert.That(unstableRate.Value, Is.Not.Null); + ClassicAssert.AreEqual(unstableRate.Value.Value, 10 * Math.Sqrt(10), Precision.DOUBLE_EPSILON); } [Test] @@ -50,8 +51,8 @@ public void TestDistributedHitsIncrementalRewind() result = events.GetRange(0, 2).CalculateUnstableRate(result); - Assert.IsNotNull(result!.Result); - Assert.AreEqual(5, result.Result, Precision.DOUBLE_EPSILON); + ClassicAssert.NotNull(result!.Result); + ClassicAssert.AreEqual(5, result.Result, Precision.DOUBLE_EPSILON); } [Test] @@ -73,8 +74,8 @@ public void TestDistributedHitsIncremental() .CalculateUnstableRate(result); } - Assert.IsNotNull(result!.Result); - Assert.AreEqual(10 * Math.Sqrt(10), result.Result, Precision.DOUBLE_EPSILON); + ClassicAssert.NotNull(result!.Result); + ClassicAssert.AreEqual(10 * Math.Sqrt(10), result.Result, Precision.DOUBLE_EPSILON); } [Test] @@ -89,7 +90,7 @@ public void TestMissesAndEmptyWindows() var unstableRate = new UnstableRate(events); - Assert.AreEqual(0, unstableRate.Value); + ClassicAssert.AreEqual(0, unstableRate.Value); } [Test] @@ -105,7 +106,7 @@ public void TestStaticRateChange() var unstableRate = new UnstableRate(events); - Assert.AreEqual(10 * 100, unstableRate.Value); + ClassicAssert.AreEqual(10 * 100, unstableRate.Value); } [Test] @@ -121,7 +122,7 @@ public void TestDynamicRateChange() var unstableRate = new UnstableRate(events); - Assert.AreEqual(10 * 100, unstableRate.Value); + ClassicAssert.AreEqual(10 * 100, unstableRate.Value); } } } diff --git a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs index d0ad2e22a457..7b87ba248115 100644 --- a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs +++ b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs @@ -5,6 +5,7 @@ using System; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Rulesets.Difficulty.Utils; namespace osu.Game.Tests.NonVisual @@ -23,7 +24,7 @@ public void Setup() [Test] public void TestEmptyQueue() { - Assert.AreEqual(0, queue.Count); + ClassicAssert.AreEqual(0, queue.Count); Assert.Throws(() => { @@ -34,7 +35,7 @@ public void TestEmptyQueue() foreach (char unused in queue) count++; - Assert.AreEqual(0, count); + ClassicAssert.AreEqual(0, count); } [Test] @@ -45,21 +46,21 @@ public void TestEnqueue() queue.Enqueue('b'); queue.Enqueue('c'); - Assert.AreEqual('c', queue[0]); - Assert.AreEqual('b', queue[1]); - Assert.AreEqual('a', queue[2]); + ClassicAssert.AreEqual('c', queue[0]); + ClassicAssert.AreEqual('b', queue[1]); + ClassicAssert.AreEqual('a', queue[2]); // Assert correct values and reverse index after enqueueing beyond initial capacity of 4 queue.Enqueue('d'); queue.Enqueue('e'); queue.Enqueue('f'); - Assert.AreEqual('f', queue[0]); - Assert.AreEqual('e', queue[1]); - Assert.AreEqual('d', queue[2]); - Assert.AreEqual('c', queue[3]); - Assert.AreEqual('b', queue[4]); - Assert.AreEqual('a', queue[5]); + ClassicAssert.AreEqual('f', queue[0]); + ClassicAssert.AreEqual('e', queue[1]); + ClassicAssert.AreEqual('d', queue[2]); + ClassicAssert.AreEqual('c', queue[3]); + ClassicAssert.AreEqual('b', queue[4]); + ClassicAssert.AreEqual('a', queue[5]); } [Test] @@ -73,13 +74,13 @@ public void TestDequeue() queue.Enqueue('f'); // Assert correct item return and no longer in queue after dequeueing - Assert.AreEqual('a', queue[5]); + ClassicAssert.AreEqual('a', queue[5]); char dequeuedItem = queue.Dequeue(); - Assert.AreEqual('a', dequeuedItem); - Assert.AreEqual(5, queue.Count); - Assert.AreEqual('f', queue[0]); - Assert.AreEqual('b', queue[4]); + ClassicAssert.AreEqual('a', dequeuedItem); + ClassicAssert.AreEqual(5, queue.Count); + ClassicAssert.AreEqual('f', queue[0]); + ClassicAssert.AreEqual('b', queue[4]); Assert.Throws(() => { char unused = queue[5]; @@ -97,8 +98,8 @@ public void TestDequeue() queue.Dequeue(); queue.Dequeue(); - Assert.AreEqual(1, queue.Count); - Assert.AreEqual('i', queue[0]); + ClassicAssert.AreEqual(1, queue.Count); + ClassicAssert.AreEqual('i', queue[0]); } [Test] @@ -114,7 +115,7 @@ public void TestClear() // Assert queue is empty after clearing queue.Clear(); - Assert.AreEqual(0, queue.Count); + ClassicAssert.AreEqual(0, queue.Count); Assert.Throws(() => { char unused = queue[0]; @@ -137,7 +138,7 @@ public void TestEnumerator() // Assert items are enumerated in correct order foreach (char item in queue) { - Assert.AreEqual(expectedValues[expectedValueIndex], item); + ClassicAssert.AreEqual(expectedValues[expectedValueIndex], item); expectedValueIndex++; } } diff --git a/osu.Game.Tests/NonVisual/SessionStaticsTest.cs b/osu.Game.Tests/NonVisual/SessionStaticsTest.cs index 5c8254b94743..b437c952160e 100644 --- a/osu.Game.Tests/NonVisual/SessionStaticsTest.cs +++ b/osu.Game.Tests/NonVisual/SessionStaticsTest.cs @@ -5,6 +5,7 @@ using System; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Configuration; using osu.Game.Online.API.Requests.Responses; @@ -26,20 +27,20 @@ public void TestSessionStaticsReset() sessionStatics.SetValue(Static.LastHoverSoundPlaybackTime, (double?)1d); sessionStatics.SetValue(Static.SeasonalBackgrounds, new APISeasonalBackgrounds { EndDate = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero) }); - Assert.IsFalse(sessionStatics.GetBindable(Static.LoginOverlayDisplayed).IsDefault); - Assert.IsFalse(sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).IsDefault); - Assert.IsFalse(sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).IsDefault); - Assert.IsFalse(sessionStatics.GetBindable(Static.LastHoverSoundPlaybackTime).IsDefault); - Assert.IsFalse(sessionStatics.GetBindable(Static.SeasonalBackgrounds).IsDefault); + ClassicAssert.False(sessionStatics.GetBindable(Static.LoginOverlayDisplayed).IsDefault); + ClassicAssert.False(sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).IsDefault); + ClassicAssert.False(sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).IsDefault); + ClassicAssert.False(sessionStatics.GetBindable(Static.LastHoverSoundPlaybackTime).IsDefault); + ClassicAssert.False(sessionStatics.GetBindable(Static.SeasonalBackgrounds).IsDefault); sessionStatics.ResetAfterInactivity(); - Assert.IsTrue(sessionStatics.GetBindable(Static.LoginOverlayDisplayed).IsDefault); - Assert.IsTrue(sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).IsDefault); - Assert.IsTrue(sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).IsDefault); + ClassicAssert.True(sessionStatics.GetBindable(Static.LoginOverlayDisplayed).IsDefault); + ClassicAssert.True(sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).IsDefault); + ClassicAssert.True(sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).IsDefault); // some statics should not reset despite inactivity. - Assert.IsFalse(sessionStatics.GetBindable(Static.LastHoverSoundPlaybackTime).IsDefault); - Assert.IsFalse(sessionStatics.GetBindable(Static.SeasonalBackgrounds).IsDefault); + ClassicAssert.False(sessionStatics.GetBindable(Static.LastHoverSoundPlaybackTime).IsDefault); + ClassicAssert.False(sessionStatics.GetBindable(Static.SeasonalBackgrounds).IsDefault); } } } diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs index 98cb66a234d3..2012052dc83f 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Audio; using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Rendering.Dummy; @@ -93,9 +94,9 @@ public void TestFallbackOrder(string[] filesInStore, string requestedComponent, var texture = legacySkin.GetTexture(requestedComponent); - Assert.IsNotNull(texture); - Assert.AreEqual(textureStore.Textures[expectedTexture].Width, texture.Width); - Assert.AreEqual(expectedScale, texture.ScaleAdjust); + Assert.That(texture, Is.Not.Null); + ClassicAssert.AreEqual(textureStore.Textures[expectedTexture].Width, texture.Width); + ClassicAssert.AreEqual(expectedScale, texture.ScaleAdjust); } [Test] @@ -106,7 +107,7 @@ public void TestReturnNullOnFallbackFailure() var texture = legacySkin.GetTexture("Gameplay/osu/followpoint"); - Assert.IsNull(texture); + ClassicAssert.Null(texture); } [Test] @@ -117,15 +118,15 @@ public void TestDisallowHighResolutionSprites() var texture = legacySkin.GetTexture("hitcircle"); - Assert.IsNotNull(texture); + Assert.That(texture, Is.Not.Null); Assert.That(texture.ScaleAdjust, Is.EqualTo(1)); var twoTimesTexture = legacySkin.GetTexture("hitcircle@2x"); - Assert.IsNotNull(twoTimesTexture); + Assert.That(twoTimesTexture, Is.Not.Null); Assert.That(twoTimesTexture.ScaleAdjust, Is.EqualTo(1)); - Assert.AreNotEqual(texture, twoTimesTexture); + ClassicAssert.AreNotEqual(texture, twoTimesTexture); } [Test] @@ -136,15 +137,15 @@ public void TestAllowHighResolutionSprites() var texture = legacySkin.GetTexture("hitcircle"); - Assert.IsNotNull(texture); + Assert.That(texture, Is.Not.Null); Assert.That(texture.ScaleAdjust, Is.EqualTo(2)); var twoTimesTexture = legacySkin.GetTexture("hitcircle@2x"); - Assert.IsNotNull(twoTimesTexture); + Assert.That(twoTimesTexture, Is.Not.Null); Assert.That(twoTimesTexture.ScaleAdjust, Is.EqualTo(2)); - Assert.AreEqual(texture, twoTimesTexture); + ClassicAssert.AreEqual(texture, twoTimesTexture); } private class TestLegacySkin : LegacySkin diff --git a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs index f860cd097a72..e3c5b92b54ad 100644 --- a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs +++ b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Tests.Beatmaps; +using osu.Game.Utils; namespace osu.Game.Tests.NonVisual { @@ -172,13 +173,15 @@ public TestDifficultyCalculator(IWorkingBeatmap beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) => new TestDifficultyAttributes { Objects = beatmap.HitObjects.ToArray() }; - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { List objects = new List(); + double clockRate = ModUtils.CalculateRateWithMods(mods); + foreach (var obj in beatmap.HitObjects.OfType()) { if (!obj.Skip) @@ -191,7 +194,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I return objects; } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new PassThroughSkill(mods) }; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { new PassThroughSkill(mods) }; private class PassThroughSkill : Skill { @@ -200,8 +203,9 @@ public PassThroughSkill(Mod[] mods) { } - public override void Process(DifficultyHitObject current) + protected override double ProcessInternal(DifficultyHitObject current) { + return 0; } public override double DifficultyValue() => 1; diff --git a/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs b/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs index 10d592364d09..374659a48fce 100644 --- a/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs +++ b/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs @@ -3,6 +3,7 @@ using System; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Extensions; namespace osu.Game.Tests.NonVisual @@ -21,7 +22,7 @@ public class TimeDisplayExtensionTest [TestCaseSource(nameof(editor_formatted_duration_tests))] public void TestEditorFormat(TimeSpan input, string expectedOutput) { - Assert.AreEqual(expectedOutput, input.ToEditorFormattedString()); + ClassicAssert.AreEqual(expectedOutput, input.ToEditorFormattedString()); } private static readonly object[][] formatted_duration_tests = @@ -35,7 +36,7 @@ public void TestEditorFormat(TimeSpan input, string expectedOutput) [TestCaseSource(nameof(formatted_duration_tests))] public void TestFormattedDuration(TimeSpan input, string expectedOutput) { - Assert.AreEqual(expectedOutput, input.ToFormattedDuration().ToString()); + ClassicAssert.AreEqual(expectedOutput, input.ToFormattedDuration().ToString()); } } } diff --git a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs index a391ec406634..f129accfa32d 100644 --- a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs +++ b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Online.Chat; namespace osu.Game.Tests.Online.Chat @@ -12,79 +13,79 @@ public class MessageNotifierTest [Test] public void TestContainsUsernameMidlinePositive() { - Assert.IsTrue(MessageNotifier.MatchUsername("This is a test message", "Test").Success); + ClassicAssert.True(MessageNotifier.MatchUsername("This is a test message", "Test").Success); } [Test] public void TestContainsUsernameStartOfLinePositive() { - Assert.IsTrue(MessageNotifier.MatchUsername("Test message", "Test").Success); + ClassicAssert.True(MessageNotifier.MatchUsername("Test message", "Test").Success); } [Test] public void TestContainsUsernameEndOfLinePositive() { - Assert.IsTrue(MessageNotifier.MatchUsername("This is a test", "Test").Success); + ClassicAssert.True(MessageNotifier.MatchUsername("This is a test", "Test").Success); } [Test] public void TestContainsUsernameMidlineNegative() { - Assert.IsFalse(MessageNotifier.MatchUsername("This is a testmessage for notifications", "Test").Success); + ClassicAssert.False(MessageNotifier.MatchUsername("This is a testmessage for notifications", "Test").Success); } [Test] public void TestContainsUsernameStartOfLineNegative() { - Assert.IsFalse(MessageNotifier.MatchUsername("Testmessage", "Test").Success); + ClassicAssert.False(MessageNotifier.MatchUsername("Testmessage", "Test").Success); } [Test] public void TestContainsUsernameEndOfLineNegative() { - Assert.IsFalse(MessageNotifier.MatchUsername("This is a notificationtest", "Test").Success); + ClassicAssert.False(MessageNotifier.MatchUsername("This is a notificationtest", "Test").Success); } [Test] public void TestContainsUsernameBetweenPunctuation() { - Assert.IsTrue(MessageNotifier.MatchUsername("Hello 'test'-message", "Test").Success); + ClassicAssert.True(MessageNotifier.MatchUsername("Hello 'test'-message", "Test").Success); } [Test] public void TestContainsUsernameUnicode() { - Assert.IsTrue(MessageNotifier.MatchUsername("Test \u0460\u0460 message", "\u0460\u0460").Success); + ClassicAssert.True(MessageNotifier.MatchUsername("Test \u0460\u0460 message", "\u0460\u0460").Success); } [Test] public void TestContainsUsernameUnicodeNegative() { - Assert.IsFalse(MessageNotifier.MatchUsername("Test ha\u0460\u0460o message", "\u0460\u0460").Success); + ClassicAssert.False(MessageNotifier.MatchUsername("Test ha\u0460\u0460o message", "\u0460\u0460").Success); } [Test] public void TestContainsUsernameSpecialCharactersPositive() { - Assert.IsTrue(MessageNotifier.MatchUsername("Test [#^-^#] message", "[#^-^#]").Success); + ClassicAssert.True(MessageNotifier.MatchUsername("Test [#^-^#] message", "[#^-^#]").Success); } [Test] public void TestContainsUsernameSpecialCharactersNegative() { - Assert.IsFalse(MessageNotifier.MatchUsername("Test pad[#^-^#]oru message", "[#^-^#]").Success); + ClassicAssert.False(MessageNotifier.MatchUsername("Test pad[#^-^#]oru message", "[#^-^#]").Success); } [Test] public void TestContainsUsernameAtSign() { - Assert.IsTrue(MessageNotifier.MatchUsername("@username hi", "username").Success); + ClassicAssert.True(MessageNotifier.MatchUsername("@username hi", "username").Success); } [Test] public void TestContainsUsernameColon() { - Assert.IsTrue(MessageNotifier.MatchUsername("username: hi", "username").Success); + ClassicAssert.True(MessageNotifier.MatchUsername("username: hi", "username").Success); } } } diff --git a/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs b/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs index 5f82d22ae836..9b99128154b4 100644 --- a/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs +++ b/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; @@ -29,17 +31,17 @@ public void Basic() new SoloScoreInfo { UserID = 3, TotalScore = 750 }, ], placement_points); - Assert.AreEqual(8, state.Users.GetOrAdd(1).Points); - Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); - Assert.AreEqual(1, state.Users.GetOrAdd(1).Rounds.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(8, state.Users.GetOrAdd(1).Points); + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(1).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(6, state.Users.GetOrAdd(2).Points); - Assert.AreEqual(3, state.Users.GetOrAdd(2).Placement); - Assert.AreEqual(3, state.Users.GetOrAdd(2).Rounds.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(6, state.Users.GetOrAdd(2).Points); + ClassicAssert.AreEqual(3, state.Users.GetOrAdd(2).Placement); + ClassicAssert.AreEqual(3, state.Users.GetOrAdd(2).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(7, state.Users.GetOrAdd(3).Points); - Assert.AreEqual(2, state.Users.GetOrAdd(3).Placement); - Assert.AreEqual(2, state.Users.GetOrAdd(3).Rounds.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(7, state.Users.GetOrAdd(3).Points); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(3).Placement); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(3).Rounds.GetOrAdd(1).Placement); // 2 -> 1 -> 3 @@ -51,17 +53,17 @@ public void Basic() new SoloScoreInfo { UserID = 3, TotalScore = 500 }, ], placement_points); - Assert.AreEqual(15, state.Users.GetOrAdd(1).Points); - Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); - Assert.AreEqual(2, state.Users.GetOrAdd(1).Rounds.GetOrAdd(2).Placement); + ClassicAssert.AreEqual(15, state.Users.GetOrAdd(1).Points); + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(1).Rounds.GetOrAdd(2).Placement); - Assert.AreEqual(14, state.Users.GetOrAdd(2).Points); - Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); - Assert.AreEqual(1, state.Users.GetOrAdd(2).Rounds.GetOrAdd(2).Placement); + ClassicAssert.AreEqual(14, state.Users.GetOrAdd(2).Points); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(2).Rounds.GetOrAdd(2).Placement); - Assert.AreEqual(13, state.Users.GetOrAdd(3).Points); - Assert.AreEqual(3, state.Users.GetOrAdd(3).Placement); - Assert.AreEqual(3, state.Users.GetOrAdd(3).Rounds.GetOrAdd(2).Placement); + ClassicAssert.AreEqual(13, state.Users.GetOrAdd(3).Points); + ClassicAssert.AreEqual(3, state.Users.GetOrAdd(3).Placement); + ClassicAssert.AreEqual(3, state.Users.GetOrAdd(3).Rounds.GetOrAdd(2).Placement); } [Test] @@ -80,21 +82,21 @@ public void MatchingScores() new SoloScoreInfo { UserID = 4, TotalScore = 500 }, ], placement_points); - Assert.AreEqual(7, state.Users.GetOrAdd(1).Points); - Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); - Assert.AreEqual(2, state.Users.GetOrAdd(1).Rounds.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(7, state.Users.GetOrAdd(1).Points); + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(1).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(7, state.Users.GetOrAdd(2).Points); - Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); - Assert.AreEqual(2, state.Users.GetOrAdd(2).Rounds.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(7, state.Users.GetOrAdd(2).Points); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(2).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(5, state.Users.GetOrAdd(3).Points); - Assert.AreEqual(3, state.Users.GetOrAdd(3).Placement); - Assert.AreEqual(4, state.Users.GetOrAdd(3).Rounds.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(5, state.Users.GetOrAdd(3).Points); + ClassicAssert.AreEqual(3, state.Users.GetOrAdd(3).Placement); + ClassicAssert.AreEqual(4, state.Users.GetOrAdd(3).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(5, state.Users.GetOrAdd(4).Points); - Assert.AreEqual(4, state.Users.GetOrAdd(4).Placement); - Assert.AreEqual(4, state.Users.GetOrAdd(4).Rounds.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(5, state.Users.GetOrAdd(4).Points); + ClassicAssert.AreEqual(4, state.Users.GetOrAdd(4).Placement); + ClassicAssert.AreEqual(4, state.Users.GetOrAdd(4).Rounds.GetOrAdd(1).Placement); } [Test] @@ -120,8 +122,8 @@ public void RoundTieBreaker() new SoloScoreInfo { UserID = 2, TotalScore = 1000 }, ], placement_points); - Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); - Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(2).Placement); } [Test] @@ -142,12 +144,40 @@ public void UserIdTieBreaker() new SoloScoreInfo { UserID = 5, TotalScore = 1000 }, ], placement_points); - Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); - Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); - Assert.AreEqual(3, state.Users.GetOrAdd(3).Placement); - Assert.AreEqual(4, state.Users.GetOrAdd(4).Placement); - Assert.AreEqual(5, state.Users.GetOrAdd(5).Placement); - Assert.AreEqual(6, state.Users.GetOrAdd(6).Placement); + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + ClassicAssert.AreEqual(3, state.Users.GetOrAdd(3).Placement); + ClassicAssert.AreEqual(4, state.Users.GetOrAdd(4).Placement); + ClassicAssert.AreEqual(5, state.Users.GetOrAdd(5).Placement); + ClassicAssert.AreEqual(6, state.Users.GetOrAdd(6).Placement); + } + + [Test] + public void AbandonOrder() + { + var state = new MatchmakingRoomState(); + + state.AdvanceRound(); + state.RecordScores( + [ + new SoloScoreInfo { UserID = 1, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 2, TotalScore = 500 }, + ], placement_points); + + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + + state.Users.GetOrAdd(1).AbandonedAt = DateTimeOffset.Now; + state.RecordScores([], placement_points); + + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(2).Placement); + + state.Users.GetOrAdd(2).AbandonedAt = DateTimeOffset.Now - TimeSpan.FromMinutes(1); + state.RecordScores([], placement_points); + + ClassicAssert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + ClassicAssert.AreEqual(2, state.Users.GetOrAdd(2).Placement); } } } diff --git a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index da250c1e0579..55984278b152 100644 --- a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Game.Beatmaps; @@ -36,7 +37,7 @@ public void TestUnknownMod() var converted = deserialized?.ToMod(new TestRuleset()); - Assert.NotNull(converted); + Assert.That(converted, Is.Not.Null); Assert.That(converted, Is.TypeOf(typeof(UnknownMod))); Assert.That(converted.Type, Is.EqualTo(ModType.System)); Assert.That(converted.Acronym, Is.EqualTo("WNG??")); @@ -157,7 +158,7 @@ public void TestSerialisedModSettingPresence() mod.TestSetting.Value = mod.TestSetting.Default; JObject serialised = JObject.Parse(JsonConvert.SerializeObject(new APIMod(mod))); - Assert.False(serialised.ContainsKey("settings")); + ClassicAssert.False(serialised.ContainsKey("settings")); } private class TestRuleset : Ruleset diff --git a/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs b/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs index c440f375fdea..da21ff6fcbf8 100644 --- a/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs +++ b/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs @@ -3,6 +3,7 @@ using MessagePack; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Online; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; @@ -24,7 +25,7 @@ public void TestSerialiseRoom() var deserialized = MessagePackSerializer.Deserialize(serialized); - Assert.IsTrue(deserialized.MatchState is TeamVersusRoomState); + ClassicAssert.True(deserialized.MatchState is TeamVersusRoomState); } [Test] @@ -35,7 +36,7 @@ public void TestSerialiseUserStateExpected() byte[] serialized = MessagePackSerializer.Serialize(typeof(MatchUserState), state); var deserialized = MessagePackSerializer.Deserialize(serialized); - Assert.IsTrue(deserialized is TeamVersusUserState); + ClassicAssert.True(deserialized is TeamVersusUserState); } [Test] @@ -66,7 +67,7 @@ public void TestSerialiseUnionSucceedsWithWorkaround() // works with custom resolver. var deserialized = MessagePackSerializer.Deserialize(serialized, SignalRUnionWorkaroundResolver.OPTIONS); - Assert.IsTrue(deserialized is TeamVersusUserState); + ClassicAssert.True(deserialized is TeamVersusUserState); } } } diff --git a/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs index 41ffd9c9a90f..ed1a7bd48306 100644 --- a/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs @@ -44,7 +44,13 @@ private void load(GameHost host) availableBeatmap = importedSet.Beatmaps[0]; unavailableBeatmap = importedSet.Beatmaps[1]; - Realm.Write(r => r.Remove(r.Find(unavailableBeatmap.ID)!)); + Realm.Write(r => + { + BeatmapInfo available = r.Find(availableBeatmap.ID)!; + available.OnlineMD5Hash = available.MD5Hash; + + r.Remove(r.Find(unavailableBeatmap.ID)!); + }); } public override void SetUpSteps() diff --git a/osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs index 220c23b5bcc9..1887d0f38115 100644 --- a/osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs @@ -27,6 +27,7 @@ using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; +using Realms; namespace osu.Game.Tests.Online { @@ -229,6 +230,14 @@ public TestBeatmapImporter(TestBeatmapManager testBeatmapManager, Storage storag return testBeatmapManager.CurrentImport = base.ImportModel(item, archive, parameters, cancellationToken); } + + protected override void PostImport(BeatmapSetInfo model, Realm realm, ImportParameters parameters) + { + foreach (var beatmap in model.Beatmaps) + beatmap.OnlineMD5Hash = beatmap.MD5Hash; + + base.PostImport(model, realm, parameters); + } } } diff --git a/osu.Game.Tests/Resources/Archives/modified-classic-20250827.osk b/osu.Game.Tests/Resources/Archives/modified-classic-20250827.osk new file mode 100644 index 000000000000..605ea60f4c74 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-classic-20250827.osk differ diff --git a/osu.Game.Tests/Resources/Requests/api-beatmaps-rankedplay.json b/osu.Game.Tests/Resources/Requests/api-beatmaps-rankedplay.json new file mode 100644 index 000000000000..416aff1ed690 --- /dev/null +++ b/osu.Game.Tests/Resources/Requests/api-beatmaps-rankedplay.json @@ -0,0 +1,1590 @@ +[ + { + "beatmapset_id": 989460, + "difficulty_rating": 8.77437, + "id": 2069833, + "mode": "osu", + "status": "ranked", + "total_length": 306, + "user_id": 11, + "version": "Endless Days", + "accuracy": 9.4, + "ar": 9.8, + "bpm": 260, + "convert": false, + "count_circles": 2075, + "count_sliders": 452, + "count_spinners": 3, + "cs": 4.2, + "deleted_at": null, + "drain": 5, + "hit_length": 306, + "is_scoreable": true, + "last_updated": "2019-10-04T20:59:44Z", + "mode_int": 0, + "passcount": 5346, + "playcount": 77382, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/2069833", + "checksum": "d6b18fbcba356cfe9c6edcb21e78dfec", + "beatmapset": { + "anime_cover": false, + "artist": "Rivers of Nihil", + "artist_unicode": "Rivers of Nihil", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/cover.jpg?1631512608", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/cover@2x.jpg?1631512608", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/card.jpg?1631512608", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/card@2x.jpg?1631512608", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/list.jpg?1631512608", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/list@2x.jpg?1631512608", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/slimcover.jpg?1631512608", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/989460\/covers\/slimcover@2x.jpg?1631512608" + }, + "creator": "vrnl", + "favourite_count": 142, + "genre_id": 11, + "hype": null, + "id": 989460, + "language_id": 2, + "nsfw": false, + "offset": 0, + "play_count": 77382, + "preview_url": "\/\/b.ppy.sh\/preview\/989460.mp3", + "source": "", + "spotlight": false, + "status": "ranked", + "title": "Hollow", + "title_unicode": "Hollow", + "track_id": 1762, + "user_id": 11, + "video": false, + "bpm": 260, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2019-10-04T20:59:43Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": null, + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2019-10-15T22:45:04Z", + "rating": 9.40909, + "storyboard": false, + "submitted_date": "2019-06-18T16:00:11Z", + "tags": "where owls know my name english progressive technical death metal featured artist", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "fail": [ + 0, + 4, + 41, + 64, + 149, + 210, + 547, + 5901, + 2455, + 595, + 1318, + 9767, + 993, + 687, + 4161, + 673, + 48, + 5, + 14, + 32, + 9, + 2, + 5, + 14, + 70, + 37, + 56, + 126, + 60, + 43, + 55, + 59, + 72, + 161, + 310, + 71, + 112, + 22, + 286, + 1038, + 5569, + 3662, + 16, + 15, + 85, + 72, + 12, + 15, + 18, + 2, + 47, + 114, + 34, + 96, + 82, + 34, + 56, + 46, + 81, + 98, + 66, + 12, + 172, + 52, + 3, + 0, + 3, + 0, + 10, + 0, + 0, + 28, + 2, + 0, + 0, + 0, + 0, + 2, + 5, + 118, + 745, + 415, + 102, + 2, + 1, + 29, + 3, + 1, + 0, + 0, + 109, + 79, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "exit": [ + 0, + 0, + 1976, + 1345, + 1068, + 877, + 261, + 1636, + 1133, + 713, + 1050, + 2397, + 1079, + 1191, + 1394, + 779, + 269, + 257, + 252, + 297, + 221, + 94, + 69, + 133, + 223, + 227, + 212, + 199, + 141, + 168, + 110, + 102, + 61, + 112, + 162, + 93, + 110, + 89, + 103, + 160, + 625, + 694, + 162, + 228, + 433, + 347, + 263, + 181, + 172, + 79, + 229, + 311, + 173, + 231, + 268, + 207, + 176, + 87, + 95, + 168, + 64, + 46, + 55, + 71, + 50, + 33, + 40, + 10, + 14, + 8, + 5, + 41, + 16, + 2, + 15, + 7, + 18, + 16, + 10, + 71, + 99, + 70, + 104, + 57, + 44, + 104, + 60, + 19, + 22, + 48, + 114, + 86, + 66, + 58, + 30, + 55, + 28, + 16, + 19, + 42 + ] + }, + "max_combo": 3275, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + }, + { + "beatmapset_id": 1681275, + "difficulty_rating": 3.72672, + "id": 3631491, + "mode": "osu", + "status": "ranked", + "total_length": 212, + "user_id": 11, + "version": "Wanpachi's Hard", + "accuracy": 6, + "ar": 8, + "bpm": 240, + "convert": false, + "count_circles": 218, + "count_sliders": 359, + "count_spinners": 2, + "cs": 3, + "deleted_at": null, + "drain": 5, + "hit_length": 180, + "is_scoreable": true, + "last_updated": "2022-07-21T06:02:13Z", + "mode_int": 0, + "passcount": 1, + "playcount": 9, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/3631491", + "checksum": "5111a3da1c545d05ff4136802ad76590", + "beatmapset": { + "anime_cover": false, + "artist": "BUTAOTOME", + "artist_unicode": "\u8c5a\u4e59\u5973", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/cover.jpg?1658383350", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/cover@2x.jpg?1658383350", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/card.jpg?1658383350", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/card@2x.jpg?1658383350", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/list.jpg?1658383350", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/list@2x.jpg?1658383350", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/slimcover.jpg?1658383350", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1681275\/covers\/slimcover@2x.jpg?1658383350" + }, + "creator": "Deca", + "favourite_count": 8, + "genre_id": 4, + "hype": null, + "id": 1681275, + "language_id": 3, + "nsfw": false, + "offset": 0, + "play_count": 28, + "preview_url": "\/\/b.ppy.sh\/preview\/1681275.mp3", + "source": "", + "spotlight": false, + "status": "ranked", + "title": "Shinsan Game", + "title_unicode": "\u8f9b\u9178\u30b2\u30fc\u30e0", + "track_id": null, + "user_id": 11, + "video": false, + "bpm": 240, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2022-07-21T06:02:11Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": [ + "osu" + ], + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2023-09-13T20:04:46Z", + "rating": 0, + "storyboard": false, + "submitted_date": "2022-01-23T05:51:07Z", + "tags": "evilelvis buta-otome hardships game comp ranko doubt \u30e9\u30f3\u30b3 jounzan natteke desu aragon lasse amb1d3x hishiro chizuru wanpachi some hero heroine bongo \u30c0\u30a6\u30c8 japanese rock", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "exit": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fail": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "max_combo": 968, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + }, + { + "beatmapset_id": 1703042, + "difficulty_rating": 2.36513, + "id": 3658033, + "mode": "osu", + "status": "ranked", + "total_length": 199, + "user_id": 11, + "version": "Jon's Normal", + "accuracy": 4, + "ar": 5, + "bpm": 184, + "convert": false, + "count_circles": 125, + "count_sliders": 237, + "count_spinners": 1, + "cs": 4, + "deleted_at": null, + "drain": 4, + "hit_length": 184, + "is_scoreable": true, + "last_updated": "2022-07-24T19:26:15Z", + "mode_int": 0, + "passcount": 0, + "playcount": 0, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/3658033", + "checksum": "e37bb04f424cfdd58481d05a61f7f642", + "beatmapset": { + "anime_cover": false, + "artist": "yuchaP", + "artist_unicode": "\u3086\u3061\u3083P", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/cover.jpg?1658690797", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/cover@2x.jpg?1658690797", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/card.jpg?1658690797", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/card@2x.jpg?1658690797", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/list.jpg?1658690797", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/list@2x.jpg?1658690797", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/slimcover.jpg?1658690797", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1703042\/covers\/slimcover@2x.jpg?1658690797" + }, + "creator": "Nevo", + "favourite_count": 3, + "genre_id": 4, + "hype": null, + "id": 1703042, + "language_id": 3, + "nsfw": false, + "offset": 0, + "play_count": 7, + "preview_url": "\/\/b.ppy.sh\/preview\/1703042.mp3", + "source": "", + "spotlight": false, + "status": "ranked", + "title": "Pokerface", + "title_unicode": "\u30dd\u30fc\u30ab\u30fc\u30d5\u30a7\u30a4\u30b9", + "track_id": null, + "user_id": 11, + "video": true, + "bpm": 184, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2022-07-24T19:26:14Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": [ + "osu" + ], + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2023-09-13T19:22:20Z", + "rating": 0, + "storyboard": false, + "submitted_date": "2022-02-19T21:39:17Z", + "tags": "vocaloid japanese cover utaite gumi len fast rvmathew jonarwhal rock jrock j-rock kaichi hnd", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "fail": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "exit": [ + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "max_combo": 619, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + }, + { + "beatmapset_id": 1789487, + "difficulty_rating": 1.98075, + "id": 3666654, + "mode": "osu", + "status": "ranked", + "total_length": 88, + "user_id": 11, + "version": "ckharv's Easy", + "accuracy": 2, + "ar": 3, + "bpm": 180, + "convert": false, + "count_circles": 77, + "count_sliders": 59, + "count_spinners": 0, + "cs": 3, + "deleted_at": null, + "drain": 2, + "hit_length": 87, + "is_scoreable": true, + "last_updated": "2022-07-25T00:02:22Z", + "mode_int": 0, + "passcount": 9, + "playcount": 22, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/3666654", + "checksum": "da748165d116b6dcbb2ff6e064cbf5bf", + "beatmapset": { + "anime_cover": false, + "artist": "Satono Diamond (CV: Tachibana Hina), Kitasan Black (CV: Yano Hinaki)", + "artist_unicode": "\u30b5\u30c8\u30ce\u30c0\u30a4\u30e4\u30e2\u30f3\u30c9 (CV\uff1a\u7acb\u82b1\u65e5\u83dc), \u30ad\u30bf\u30b5\u30f3\u30d6\u30e9\u30c3\u30af (CV\uff1a\u77e2\u91ce\u5983\u83dc\u559c)", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/cover.jpg?1658707359", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/cover@2x.jpg?1658707359", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/card.jpg?1658707359", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/card@2x.jpg?1658707359", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/list.jpg?1658707359", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/list@2x.jpg?1658707359", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/slimcover.jpg?1658707359", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1789487\/covers\/slimcover@2x.jpg?1658707359" + }, + "creator": "Zekk", + "favourite_count": 9, + "genre_id": 5, + "hype": null, + "id": 1789487, + "language_id": 3, + "nsfw": false, + "offset": 0, + "play_count": 114, + "preview_url": "\/\/b.ppy.sh\/preview\/1789487.mp3", + "source": "\u30a6\u30de\u5a18 \u30d7\u30ea\u30c6\u30a3\u30fc\u30c0\u30fc\u30d3\u30fc", + "spotlight": false, + "status": "ranked", + "title": "Ambitious World (PV Size)", + "title_unicode": "Ambitious World (PV Size)", + "track_id": null, + "user_id": 11, + "video": false, + "bpm": 180, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2022-07-25T00:02:21Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": [ + "osu" + ], + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2023-09-13T19:24:15Z", + "rating": 10, + "storyboard": false, + "submitted_date": "2022-06-19T22:22:08Z", + "tags": "horse girls uma musume pretty derby japanese pop video game anime kowari ckharv satonodiamond nanoya koldnoodl", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "exit": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fail": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "max_combo": 239, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + }, + { + "beatmapset_id": 1811717, + "difficulty_rating": 2.29139, + "id": 3716286, + "mode": "osu", + "status": "ranked", + "total_length": 125, + "user_id": 11, + "version": "Xahlt's Bot Diff", + "accuracy": 4.5, + "ar": 5.5, + "bpm": 180.55, + "convert": false, + "count_circles": 113, + "count_sliders": 130, + "count_spinners": 0, + "cs": 3.3, + "deleted_at": null, + "drain": 3, + "hit_length": 116, + "is_scoreable": true, + "last_updated": "2022-07-24T18:59:31Z", + "mode_int": 0, + "passcount": 0, + "playcount": 1, + "ranked": 1, + "url": "https:\/\/dev.ppy.sh\/beatmaps\/3716286", + "checksum": "fe662e406ce708e33dd6264f59c6f9cd", + "beatmapset": { + "anime_cover": false, + "artist": "Porter Robinson", + "artist_unicode": "Porter Robinson", + "covers": { + "cover": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/cover.jpg?1658689187", + "cover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/cover@2x.jpg?1658689187", + "card": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/card.jpg?1658689187", + "card@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/card@2x.jpg?1658689187", + "list": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/list.jpg?1658689187", + "list@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/list@2x.jpg?1658689187", + "slimcover": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/slimcover.jpg?1658689187", + "slimcover@2x": "https:\/\/assets.ppy.sh\/beatmaps\/1811717\/covers\/slimcover@2x.jpg?1658689187" + }, + "creator": "Sotarks", + "favourite_count": 29, + "genre_id": 5, + "hype": null, + "id": 1811717, + "language_id": 2, + "nsfw": false, + "offset": 0, + "play_count": 12, + "preview_url": "\/\/b.ppy.sh\/preview\/1811717.mp3", + "source": "League of Legends", + "spotlight": false, + "status": "ranked", + "title": "Everything Goes On (Star Guardian Version) (Sped Up Ver.)", + "title_unicode": "Everything Goes On (Star Guardian Version) (Sped Up Ver.)", + "track_id": null, + "user_id": 11, + "video": true, + "bpm": 180.55, + "can_be_hyped": false, + "deleted_at": null, + "discussion_enabled": true, + "discussion_locked": false, + "is_scoreable": true, + "last_updated": "2022-07-24T18:59:29Z", + "legacy_thread_url": null, + "nominations_summary": { + "current": 2, + "eligible_main_rulesets": [ + "osu" + ], + "required_meta": { + "main_ruleset": 2, + "non_main_ruleset": 1 + } + }, + "ranked": 1, + "ranked_date": "2023-09-12T20:24:15Z", + "rating": 9.88889, + "storyboard": false, + "submitted_date": "2022-07-21T19:32:53Z", + "tags": "speed 2022 official music video red dog culture house riot games english electronic pop male vocals vocalist edit banter pepekcz xahlt kuon- gweon sua", + "availability": { + "download_disabled": false, + "more_information": null + }, + "has_favourited": false, + "ratings": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "current_user_playcount": 0, + "failtimes": { + "exit": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 18, + 9, + 9, + 0, + 0, + 9, + 0, + 9, + 0, + 0, + 9, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fail": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "max_combo": 406, + "owners": [ + { + "id": 11, + "username": "ThePooN" + } + ] + } +] \ No newline at end of file diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index 469bc8ee7394..d7e2ea48c55d 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -9,7 +9,7 @@ using System.Linq; using System.Text; using System.Threading; -using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Logging; @@ -55,7 +55,7 @@ public static string GetQuickTestBeatmapForImport() using (var newFile = File.Create(tempPath)) stream.CopyTo(newFile); - Assert.IsTrue(File.Exists(tempPath)); + ClassicAssert.True(File.Exists(tempPath)); return tempPath; } @@ -72,7 +72,7 @@ public static string GetTestBeatmapForImport(bool virtualTrack = false) using (var newFile = File.Create(tempPath)) stream.CopyTo(newFile); - Assert.IsTrue(File.Exists(tempPath)); + ClassicAssert.True(File.Exists(tempPath)); return tempPath; } diff --git a/osu.Game.Tests/Resources/special-skin/score-0@2x.png b/osu.Game.Tests/Resources/special-skin/score-0@2x.png new file mode 100644 index 000000000000..973f2a51be30 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-0@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-1@2x.png b/osu.Game.Tests/Resources/special-skin/score-1@2x.png new file mode 100644 index 000000000000..78cadf49bdf2 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-1@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-2@2x.png b/osu.Game.Tests/Resources/special-skin/score-2@2x.png new file mode 100644 index 000000000000..10dff4ccfa26 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-2@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-3@2x.png b/osu.Game.Tests/Resources/special-skin/score-3@2x.png new file mode 100644 index 000000000000..89eb5d322b48 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-3@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-4@2x.png b/osu.Game.Tests/Resources/special-skin/score-4@2x.png new file mode 100644 index 000000000000..b44e9ec4602c Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-4@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-5@2x.png b/osu.Game.Tests/Resources/special-skin/score-5@2x.png new file mode 100644 index 000000000000..70b2632beb10 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-5@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-6@2x.png b/osu.Game.Tests/Resources/special-skin/score-6@2x.png new file mode 100644 index 000000000000..0e7bfc377631 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-6@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-7@2x.png b/osu.Game.Tests/Resources/special-skin/score-7@2x.png new file mode 100644 index 000000000000..a48c01fa7142 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-7@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-8@2x.png b/osu.Game.Tests/Resources/special-skin/score-8@2x.png new file mode 100644 index 000000000000..5726c519ae97 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-8@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-9@2x.png b/osu.Game.Tests/Resources/special-skin/score-9@2x.png new file mode 100644 index 000000000000..07bfb61b0e01 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-9@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-comma@2x.png b/osu.Game.Tests/Resources/special-skin/score-comma@2x.png new file mode 100644 index 000000000000..c51f6702a915 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-comma@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-dot@2x.png b/osu.Game.Tests/Resources/special-skin/score-dot@2x.png new file mode 100644 index 000000000000..58f38a22b5a0 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-dot@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-percent@2x.png b/osu.Game.Tests/Resources/special-skin/score-percent@2x.png new file mode 100644 index 000000000000..9a250ac4e2e5 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-percent@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-pp@2x.png b/osu.Game.Tests/Resources/special-skin/score-pp@2x.png new file mode 100644 index 000000000000..e8616e72841c Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-pp@2x.png differ diff --git a/osu.Game.Tests/Resources/special-skin/score-x@2x.png b/osu.Game.Tests/Resources/special-skin/score-x@2x.png new file mode 100644 index 000000000000..c61724db1f02 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/score-x@2x.png differ diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index f45422e0c467..db900d57fc0c 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets; @@ -174,7 +175,7 @@ public void TestEmptyBeatmap( [TestCase(HitResult.LargeBonus, HitResult.IgnoreMiss)] public void TestMinResults(HitResult hitResult, HitResult expectedMinResult) { - Assert.AreEqual(expectedMinResult, new TestJudgement(hitResult).MinResult); + ClassicAssert.AreEqual(expectedMinResult, new TestJudgement(hitResult).MinResult); } [TestCase(HitResult.None, false)] @@ -195,7 +196,7 @@ public void TestMinResults(HitResult hitResult, HitResult expectedMinResult) [TestCase(HitResult.LargeBonus, false)] public void TestAffectsCombo(HitResult hitResult, bool expectedReturnValue) { - Assert.AreEqual(expectedReturnValue, hitResult.AffectsCombo()); + ClassicAssert.AreEqual(expectedReturnValue, hitResult.AffectsCombo()); } [TestCase(HitResult.None, false)] @@ -216,7 +217,7 @@ public void TestAffectsCombo(HitResult hitResult, bool expectedReturnValue) [TestCase(HitResult.LargeBonus, false)] public void TestAffectsAccuracy(HitResult hitResult, bool expectedReturnValue) { - Assert.AreEqual(expectedReturnValue, hitResult.AffectsAccuracy()); + ClassicAssert.AreEqual(expectedReturnValue, hitResult.AffectsAccuracy()); } [TestCase(HitResult.None, false)] @@ -237,7 +238,7 @@ public void TestAffectsAccuracy(HitResult hitResult, bool expectedReturnValue) [TestCase(HitResult.LargeBonus, true)] public void TestIsBonus(HitResult hitResult, bool expectedReturnValue) { - Assert.AreEqual(expectedReturnValue, hitResult.IsBonus()); + ClassicAssert.AreEqual(expectedReturnValue, hitResult.IsBonus()); } [TestCase(HitResult.None, false)] @@ -258,7 +259,7 @@ public void TestIsBonus(HitResult hitResult, bool expectedReturnValue) [TestCase(HitResult.LargeBonus, true)] public void TestIsHit(HitResult hitResult, bool expectedReturnValue) { - Assert.AreEqual(expectedReturnValue, hitResult.IsHit()); + ClassicAssert.AreEqual(expectedReturnValue, hitResult.IsHit()); } [TestCase(HitResult.None, false)] @@ -279,7 +280,7 @@ public void TestIsHit(HitResult hitResult, bool expectedReturnValue) [TestCase(HitResult.LargeBonus, true)] public void TestIsScorable(HitResult hitResult, bool expectedReturnValue) { - Assert.AreEqual(expectedReturnValue, hitResult.IsScorable()); + ClassicAssert.AreEqual(expectedReturnValue, hitResult.IsScorable()); } #pragma warning disable CS0618 @@ -526,7 +527,7 @@ protected override double ComputeTotalScore(double comboProgress, double accurac // ReSharper disable once MemberHidesStaticFromOuterClass private class TestRuleset : Ruleset { - protected override IEnumerable GetValidHitResults() => new[] { HitResult.Great }; + public override IEnumerable GetValidHitResults() => new[] { HitResult.Great }; public override IEnumerable GetModsFor(ModType type) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index 6558834a63bc..b1144c5bc257 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Platform; @@ -57,13 +58,13 @@ public void TestBasicImport() var imported = LoadScoreIntoOsu(osu, toImport); - Assert.AreEqual(toImport.Rank, imported.Rank); - Assert.AreEqual(toImport.TotalScore, imported.TotalScore); - Assert.AreEqual(toImport.Accuracy, imported.Accuracy); - Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); - Assert.AreEqual(toImport.User.Username, imported.User.Username); - Assert.AreEqual(toImport.Date, imported.Date); - Assert.AreEqual(toImport.OnlineID, imported.OnlineID); + ClassicAssert.AreEqual(toImport.Rank, imported.Rank); + ClassicAssert.AreEqual(toImport.TotalScore, imported.TotalScore); + ClassicAssert.AreEqual(toImport.Accuracy, imported.Accuracy); + ClassicAssert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + ClassicAssert.AreEqual(toImport.User.Username, imported.User.Username); + ClassicAssert.AreEqual(toImport.Date, imported.Date); + ClassicAssert.AreEqual(toImport.OnlineID, imported.OnlineID); } finally { @@ -110,13 +111,13 @@ public void TestLastPlayedUpdate(bool isLocalUser) var imported = LoadScoreIntoOsu(osu, toImport); - Assert.AreEqual(toImport.Rank, imported.Rank); - Assert.AreEqual(toImport.TotalScore, imported.TotalScore); - Assert.AreEqual(toImport.Accuracy, imported.Accuracy); - Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); - Assert.AreEqual(toImport.User.Username, imported.User.Username); - Assert.AreEqual(toImport.Date, imported.Date); - Assert.AreEqual(toImport.OnlineID, imported.OnlineID); + ClassicAssert.AreEqual(toImport.Rank, imported.Rank); + ClassicAssert.AreEqual(toImport.TotalScore, imported.TotalScore); + ClassicAssert.AreEqual(toImport.Accuracy, imported.Accuracy); + ClassicAssert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + ClassicAssert.AreEqual(toImport.User.Username, imported.User.Username); + ClassicAssert.AreEqual(toImport.Date, imported.Date); + ClassicAssert.AreEqual(toImport.OnlineID, imported.OnlineID); if (isLocalUser) Assert.That(imported.BeatmapInfo!.LastPlayed, Is.EqualTo(replayDate)); @@ -165,13 +166,13 @@ public void TestLastPlayedNotUpdatedDueToNewerPlays() var imported = LoadScoreIntoOsu(osu, toImport); - Assert.AreEqual(toImport.Rank, imported.Rank); - Assert.AreEqual(toImport.TotalScore, imported.TotalScore); - Assert.AreEqual(toImport.Accuracy, imported.Accuracy); - Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); - Assert.AreEqual(toImport.User.Username, imported.User.Username); - Assert.AreEqual(toImport.Date, imported.Date); - Assert.AreEqual(toImport.OnlineID, imported.OnlineID); + ClassicAssert.AreEqual(toImport.Rank, imported.Rank); + ClassicAssert.AreEqual(toImport.TotalScore, imported.TotalScore); + ClassicAssert.AreEqual(toImport.Accuracy, imported.Accuracy); + ClassicAssert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + ClassicAssert.AreEqual(toImport.User.Username, imported.User.Username); + ClassicAssert.AreEqual(toImport.Date, imported.Date); + ClassicAssert.AreEqual(toImport.OnlineID, imported.OnlineID); Assert.That(imported.BeatmapInfo!.LastPlayed, Is.EqualTo(new DateTimeOffset(2023, 10, 30, 0, 0, 0, TimeSpan.Zero))); } @@ -204,8 +205,8 @@ public void TestImportMods() var imported = LoadScoreIntoOsu(osu, toImport); - Assert.IsTrue(imported.Mods.Any(m => m is OsuModHardRock)); - Assert.IsTrue(imported.Mods.Any(m => m is OsuModDoubleTime)); + ClassicAssert.True(imported.Mods.Any(m => m is OsuModHardRock)); + ClassicAssert.True(imported.Mods.Any(m => m is OsuModDoubleTime)); Assert.That(imported.ClientVersion, Is.EqualTo(toImport.ClientVersion)); } finally @@ -269,8 +270,8 @@ public void TestImportStatistics() var imported = LoadScoreIntoOsu(osu, toImport); - Assert.AreEqual(toImport.Statistics[HitResult.Perfect], imported.Statistics[HitResult.Perfect]); - Assert.AreEqual(toImport.Statistics[HitResult.Miss], imported.Statistics[HitResult.Miss]); + ClassicAssert.AreEqual(toImport.Statistics[HitResult.Perfect], imported.Statistics[HitResult.Perfect]); + ClassicAssert.AreEqual(toImport.Statistics[HitResult.Miss], imported.Statistics[HitResult.Miss]); } finally { @@ -364,15 +365,15 @@ public void TestUserLookedUpByUsernameForOnlineScoreIfUserIDMissing() var imported = LoadScoreIntoOsu(osu, toImport); - Assert.AreEqual(toImport.Rank, imported.Rank); - Assert.AreEqual(toImport.TotalScore, imported.TotalScore); - Assert.AreEqual(toImport.Accuracy, imported.Accuracy); - Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); - Assert.AreEqual(toImport.User.Username, imported.User.Username); - Assert.AreEqual(toImport.Date, imported.Date); - Assert.AreEqual(toImport.OnlineID, imported.OnlineID); - Assert.AreEqual(toImport.User.Username, imported.RealmUser.Username); - Assert.AreEqual(1234, imported.RealmUser.OnlineID); + ClassicAssert.AreEqual(toImport.Rank, imported.Rank); + ClassicAssert.AreEqual(toImport.TotalScore, imported.TotalScore); + ClassicAssert.AreEqual(toImport.Accuracy, imported.Accuracy); + ClassicAssert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + ClassicAssert.AreEqual(toImport.User.Username, imported.User.Username); + ClassicAssert.AreEqual(toImport.Date, imported.Date); + ClassicAssert.AreEqual(toImport.OnlineID, imported.OnlineID); + ClassicAssert.AreEqual(toImport.User.Username, imported.RealmUser.Username); + ClassicAssert.AreEqual(1234, imported.RealmUser.OnlineID); } finally { @@ -430,15 +431,15 @@ public void TestUserLookedUpByUsernameForLegacyOnlineScore() var imported = LoadScoreIntoOsu(osu, toImport); - Assert.AreEqual(toImport.Rank, imported.Rank); - Assert.AreEqual(toImport.TotalScore, imported.TotalScore); - Assert.AreEqual(toImport.Accuracy, imported.Accuracy); - Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); - Assert.AreEqual(toImport.User.Username, imported.User.Username); - Assert.AreEqual(toImport.Date, imported.Date); - Assert.AreEqual(toImport.OnlineID, imported.OnlineID); - Assert.AreEqual(toImport.User.Username, imported.RealmUser.Username); - Assert.AreEqual(1234, imported.RealmUser.OnlineID); + ClassicAssert.AreEqual(toImport.Rank, imported.Rank); + ClassicAssert.AreEqual(toImport.TotalScore, imported.TotalScore); + ClassicAssert.AreEqual(toImport.Accuracy, imported.Accuracy); + ClassicAssert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + ClassicAssert.AreEqual(toImport.User.Username, imported.User.Username); + ClassicAssert.AreEqual(toImport.Date, imported.Date); + ClassicAssert.AreEqual(toImport.OnlineID, imported.OnlineID); + ClassicAssert.AreEqual(toImport.User.Username, imported.RealmUser.Username); + ClassicAssert.AreEqual(1234, imported.RealmUser.OnlineID); } finally { @@ -497,14 +498,14 @@ public void TestUserNotLookedUpForOfflineScoreIfUserIDMissing() var imported = LoadScoreIntoOsu(osu, toImport); - Assert.AreEqual(toImport.Rank, imported.Rank); - Assert.AreEqual(toImport.TotalScore, imported.TotalScore); - Assert.AreEqual(toImport.Accuracy, imported.Accuracy); - Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); - Assert.AreEqual(toImport.User.Username, imported.User.Username); - Assert.AreEqual(toImport.Date, imported.Date); - Assert.AreEqual(toImport.OnlineID, imported.OnlineID); - Assert.AreEqual(toImport.User.Username, imported.RealmUser.Username); + ClassicAssert.AreEqual(toImport.Rank, imported.Rank); + ClassicAssert.AreEqual(toImport.TotalScore, imported.TotalScore); + ClassicAssert.AreEqual(toImport.Accuracy, imported.Accuracy); + ClassicAssert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + ClassicAssert.AreEqual(toImport.User.Username, imported.User.Username); + ClassicAssert.AreEqual(toImport.Date, imported.Date); + ClassicAssert.AreEqual(toImport.OnlineID, imported.OnlineID); + ClassicAssert.AreEqual(toImport.User.Username, imported.RealmUser.Username); Assert.That(imported.RealmUser.OnlineID, Is.LessThanOrEqualTo(1)); } finally @@ -564,15 +565,15 @@ public void TestUserLookedUpByOnlineIDIfPresent([Values] bool isOnlineScore) var imported = LoadScoreIntoOsu(osu, toImport); - Assert.AreEqual(toImport.Rank, imported.Rank); - Assert.AreEqual(toImport.TotalScore, imported.TotalScore); - Assert.AreEqual(toImport.Accuracy, imported.Accuracy); - Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); - Assert.AreEqual(toImport.Date, imported.Date); - Assert.AreEqual(toImport.OnlineID, imported.OnlineID); - Assert.AreEqual("Some other guy", imported.RealmUser.Username); - Assert.AreEqual(5555, imported.RealmUser.OnlineID); - Assert.AreEqual(CountryCode.DE, imported.RealmUser.CountryCode); + ClassicAssert.AreEqual(toImport.Rank, imported.Rank); + ClassicAssert.AreEqual(toImport.TotalScore, imported.TotalScore); + ClassicAssert.AreEqual(toImport.Accuracy, imported.Accuracy); + ClassicAssert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + ClassicAssert.AreEqual(toImport.Date, imported.Date); + ClassicAssert.AreEqual(toImport.OnlineID, imported.OnlineID); + ClassicAssert.AreEqual("Some other guy", imported.RealmUser.Username); + ClassicAssert.AreEqual(5555, imported.RealmUser.OnlineID); + ClassicAssert.AreEqual(CountryCode.DE, imported.RealmUser.CountryCode); } finally { diff --git a/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs index 0994803d8303..94c34971d4b7 100644 --- a/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs @@ -4,6 +4,7 @@ #nullable disable using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Rulesets.UI.Scrolling.Algorithms; namespace osu.Game.Tests.ScrollAlgorithms @@ -22,33 +23,33 @@ public void Setup() [Test] public void TestPointDisplayStartTime() { - Assert.AreEqual(-8000, algorithm.GetDisplayStartTime(2000, 0, 10000, 1)); - Assert.AreEqual(-3000, algorithm.GetDisplayStartTime(2000, 0, 5000, 1)); - Assert.AreEqual(2000, algorithm.GetDisplayStartTime(7000, 0, 5000, 1)); - Assert.AreEqual(7000, algorithm.GetDisplayStartTime(17000, 0, 10000, 1)); + ClassicAssert.AreEqual(-8000, algorithm.GetDisplayStartTime(2000, 0, 10000, 1)); + ClassicAssert.AreEqual(-3000, algorithm.GetDisplayStartTime(2000, 0, 5000, 1)); + ClassicAssert.AreEqual(2000, algorithm.GetDisplayStartTime(7000, 0, 5000, 1)); + ClassicAssert.AreEqual(7000, algorithm.GetDisplayStartTime(17000, 0, 10000, 1)); } [Test] public void TestObjectDisplayStartTime() { - Assert.AreEqual(900, algorithm.GetDisplayStartTime(2000, 50, 1000, 500)); // 2000 - (1 + 50 / 500) * 1000 - Assert.AreEqual(8900, algorithm.GetDisplayStartTime(10000, 50, 1000, 500)); // 10000 - (1 + 50 / 500) * 1000 - Assert.AreEqual(13500, algorithm.GetDisplayStartTime(15000, 250, 1000, 500)); // 15000 - (1 + 250 / 500) * 1000 - Assert.AreEqual(19000, algorithm.GetDisplayStartTime(25000, 100, 5000, 500)); // 25000 - (1 + 100 / 500) * 5000 + ClassicAssert.AreEqual(900, algorithm.GetDisplayStartTime(2000, 50, 1000, 500)); // 2000 - (1 + 50 / 500) * 1000 + ClassicAssert.AreEqual(8900, algorithm.GetDisplayStartTime(10000, 50, 1000, 500)); // 10000 - (1 + 50 / 500) * 1000 + ClassicAssert.AreEqual(13500, algorithm.GetDisplayStartTime(15000, 250, 1000, 500)); // 15000 - (1 + 250 / 500) * 1000 + ClassicAssert.AreEqual(19000, algorithm.GetDisplayStartTime(25000, 100, 5000, 500)); // 25000 - (1 + 100 / 500) * 5000 } [Test] public void TestLength() { - Assert.AreEqual(1f / 5, algorithm.GetLength(0, 1000, 5000, 1)); - Assert.AreEqual(1f / 5, algorithm.GetLength(6000, 7000, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.GetLength(0, 1000, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.GetLength(6000, 7000, 5000, 1)); } [Test] public void TestPosition() { - Assert.AreEqual(1f / 5, algorithm.PositionAt(1000, 0, 5000, 1)); - Assert.AreEqual(1f / 5, algorithm.PositionAt(6000, 5000, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.PositionAt(1000, 0, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.PositionAt(6000, 5000, 5000, 1)); } [TestCase(1000)] @@ -58,8 +59,8 @@ public void TestPosition() [TestCase(25000)] public void TestTime(double time) { - Assert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 0, 5000, 1), 0, 5000, 1), 0.001); - Assert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 5000, 5000, 1), 5000, 5000, 1), 0.001); + ClassicAssert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 0, 5000, 1), 0, 5000, 1), 0.001); + ClassicAssert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 5000, 5000, 1), 5000, 5000, 1), 0.001); } } } diff --git a/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs index c1f647cb0719..f954f13b0b11 100644 --- a/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs @@ -4,6 +4,7 @@ #nullable disable using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Lists; using osu.Game.Rulesets.Timing; using osu.Game.Rulesets.UI.Scrolling.Algorithms; @@ -31,37 +32,37 @@ public void Setup() [Test] public void TestPointDisplayStartTime() { - Assert.AreEqual(1000, algorithm.GetDisplayStartTime(2000, 0, 1000, 1)); // Like constant - Assert.AreEqual(10000, algorithm.GetDisplayStartTime(10500, 0, 1000, 1)); // 10500 - (1000 * 0.5) - Assert.AreEqual(20000, algorithm.GetDisplayStartTime(22000, 0, 1000, 1)); // 23000 - (1000 / 0.5) + ClassicAssert.AreEqual(1000, algorithm.GetDisplayStartTime(2000, 0, 1000, 1)); // Like constant + ClassicAssert.AreEqual(10000, algorithm.GetDisplayStartTime(10500, 0, 1000, 1)); // 10500 - (1000 * 0.5) + ClassicAssert.AreEqual(20000, algorithm.GetDisplayStartTime(22000, 0, 1000, 1)); // 23000 - (1000 / 0.5) } [Test] public void TestObjectDisplayStartTime() { - Assert.AreEqual(900, algorithm.GetDisplayStartTime(2000, 50, 1000, 500)); // 2000 - (1 + 50 / 500) * 1000 / 1 - Assert.AreEqual(9450, algorithm.GetDisplayStartTime(10000, 50, 1000, 500)); // 10000 - (1 + 50 / 500) * 1000 / 2 - Assert.AreEqual(14250, algorithm.GetDisplayStartTime(15000, 250, 1000, 500)); // 15000 - (1 + 250 / 500) * 1000 / 2 - Assert.AreEqual(16500, algorithm.GetDisplayStartTime(18000, 250, 2000, 500)); // 18000 - (1 + 250 / 500) * 2000 / 2 - Assert.AreEqual(17800, algorithm.GetDisplayStartTime(20000, 50, 1000, 500)); // 20000 - (1 + 50 / 500) * 1000 / 0.5 - Assert.AreEqual(19800, algorithm.GetDisplayStartTime(22000, 50, 1000, 500)); // 22000 - (1 + 50 / 500) * 1000 / 0.5 + ClassicAssert.AreEqual(900, algorithm.GetDisplayStartTime(2000, 50, 1000, 500)); // 2000 - (1 + 50 / 500) * 1000 / 1 + ClassicAssert.AreEqual(9450, algorithm.GetDisplayStartTime(10000, 50, 1000, 500)); // 10000 - (1 + 50 / 500) * 1000 / 2 + ClassicAssert.AreEqual(14250, algorithm.GetDisplayStartTime(15000, 250, 1000, 500)); // 15000 - (1 + 250 / 500) * 1000 / 2 + ClassicAssert.AreEqual(16500, algorithm.GetDisplayStartTime(18000, 250, 2000, 500)); // 18000 - (1 + 250 / 500) * 2000 / 2 + ClassicAssert.AreEqual(17800, algorithm.GetDisplayStartTime(20000, 50, 1000, 500)); // 20000 - (1 + 50 / 500) * 1000 / 0.5 + ClassicAssert.AreEqual(19800, algorithm.GetDisplayStartTime(22000, 50, 1000, 500)); // 22000 - (1 + 50 / 500) * 1000 / 0.5 } [Test] public void TestLength() { - Assert.AreEqual(1f / 5, algorithm.GetLength(0, 1000, 5000, 1)); // Like constant - Assert.AreEqual(1f / 5, algorithm.GetLength(10000, 10500, 5000, 1)); // (10500 - 10000) / 0.5 / 5000 - Assert.AreEqual(1f / 5, algorithm.GetLength(20000, 22000, 5000, 1)); // (22000 - 20000) * 0.5 / 5000 + ClassicAssert.AreEqual(1f / 5, algorithm.GetLength(0, 1000, 5000, 1)); // Like constant + ClassicAssert.AreEqual(1f / 5, algorithm.GetLength(10000, 10500, 5000, 1)); // (10500 - 10000) / 0.5 / 5000 + ClassicAssert.AreEqual(1f / 5, algorithm.GetLength(20000, 22000, 5000, 1)); // (22000 - 20000) * 0.5 / 5000 } [Test] public void TestPosition() { // Basically same calculations as TestLength() - Assert.AreEqual(1f / 5, algorithm.PositionAt(1000, 0, 5000, 1)); - Assert.AreEqual(1f / 5, algorithm.PositionAt(10500, 10000, 5000, 1)); - Assert.AreEqual(1f / 5, algorithm.PositionAt(22000, 20000, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.PositionAt(1000, 0, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.PositionAt(10500, 10000, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.PositionAt(22000, 20000, 5000, 1)); } [TestCase(1000)] @@ -73,8 +74,8 @@ public void TestPosition() + "Ideally, scrolling should be changed to constant or sequential during editing of hitobjects.")] public void TestTime(double time) { - Assert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 0, 5000, 1), 0, 5000, 1), 0.001); - Assert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 5000, 5000, 1), 5000, 5000, 1), 0.001); + ClassicAssert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 0, 5000, 1), 0, 5000, 1), 0.001); + ClassicAssert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 5000, 5000, 1), 5000, 5000, 1), 0.001); } } } diff --git a/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs index ca6ac63619f4..e392c7030dc7 100644 --- a/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs @@ -4,6 +4,7 @@ #nullable disable using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Lists; using osu.Game.Rulesets.Timing; using osu.Game.Rulesets.UI.Scrolling.Algorithms; @@ -32,9 +33,9 @@ public void Setup() public void TestDisplayStartTime() { // easy cases - time range adjusted for velocity fits within control point duration - Assert.AreEqual(2500, algorithm.GetDisplayStartTime(5000, 0, 2500, 1)); // 5000 - (2500 / 1) - Assert.AreEqual(13750, algorithm.GetDisplayStartTime(15000, 0, 2500, 1)); // 15000 - (2500 / 2) - Assert.AreEqual(20000, algorithm.GetDisplayStartTime(25000, 0, 2500, 1)); // 25000 - (2500 / 0.5) + ClassicAssert.AreEqual(2500, algorithm.GetDisplayStartTime(5000, 0, 2500, 1)); // 5000 - (2500 / 1) + ClassicAssert.AreEqual(13750, algorithm.GetDisplayStartTime(15000, 0, 2500, 1)); // 15000 - (2500 / 2) + ClassicAssert.AreEqual(20000, algorithm.GetDisplayStartTime(25000, 0, 2500, 1)); // 25000 - (2500 / 0.5) // hard case - time range adjusted for velocity exceeds control point duration @@ -46,24 +47,24 @@ public void TestDisplayStartTime() // minus one scroll length allowance = 12500 - 1000 = 11500 = 11.5 [scroll lengths] // therefore the start time lies within the second multiplier point (because 11.5 < 4 + 8) // its exact time position is = 10000 + 7.5 * (2500 / 2) = 19375 - Assert.AreEqual(19375, algorithm.GetDisplayStartTime(22500, 0, 2500, 1000)); + ClassicAssert.AreEqual(19375, algorithm.GetDisplayStartTime(22500, 0, 2500, 1000)); } [Test] public void TestLength() { - Assert.AreEqual(1f / 5, algorithm.GetLength(0, 1000, 5000, 1)); // Like constant - Assert.AreEqual(1f / 5, algorithm.GetLength(10000, 10500, 5000, 1)); // (10500 - 10000) / 0.5 / 5000 - Assert.AreEqual(1f / 5, algorithm.GetLength(20000, 22000, 5000, 1)); // (22000 - 20000) * 0.5 / 5000 + ClassicAssert.AreEqual(1f / 5, algorithm.GetLength(0, 1000, 5000, 1)); // Like constant + ClassicAssert.AreEqual(1f / 5, algorithm.GetLength(10000, 10500, 5000, 1)); // (10500 - 10000) / 0.5 / 5000 + ClassicAssert.AreEqual(1f / 5, algorithm.GetLength(20000, 22000, 5000, 1)); // (22000 - 20000) * 0.5 / 5000 } [Test] public void TestPosition() { // Basically same calculations as TestLength() - Assert.AreEqual(1f / 5, algorithm.PositionAt(1000, 0, 5000, 1)); - Assert.AreEqual(1f / 5, algorithm.PositionAt(10500, 10000, 5000, 1)); - Assert.AreEqual(1f / 5, algorithm.PositionAt(22000, 20000, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.PositionAt(1000, 0, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.PositionAt(10500, 10000, 5000, 1)); + ClassicAssert.AreEqual(1f / 5, algorithm.PositionAt(22000, 20000, 5000, 1)); } [TestCase(1000)] @@ -73,8 +74,8 @@ public void TestPosition() [TestCase(25000)] public void TestTime(double time) { - Assert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 0, 5000, 1), 0, 5000, 1), 0.001); - Assert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 5000, 5000, 1), 5000, 5000, 1), 0.001); + ClassicAssert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 0, 5000, 1), 0, 5000, 1), 0.001); + ClassicAssert.AreEqual(time, algorithm.TimeAt(algorithm.PositionAt(time, 5000, 5000, 1), 5000, 5000, 1), 0.001); } } } diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index 2535d5b2e295..ffb97eb5cc66 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Threading.Tasks; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Allocation; using osu.Framework.Platform; using osu.Game.Database; @@ -15,6 +16,8 @@ using osu.Game.Skinning; using osu.Game.Tests.Resources; using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Writers.Zip; namespace osu.Game.Tests.Skins.IO { @@ -241,21 +244,21 @@ public Task TestExportThenImportDefaultSkin() => runSkinTest(async osu => await skinManager.CurrentSkinInfo.Value.PerformRead(async s => { - Assert.IsFalse(s.Protected); - Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); + ClassicAssert.False(s.Protected); + ClassicAssert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); await new LegacySkinExporter(osu.Dependencies.Get()).ExportToStreamAsync(skinManager.CurrentSkinInfo.Value, exportStream); - Assert.Greater(exportStream.Length, 0); + ClassicAssert.Greater(exportStream.Length, 0); }); var imported = await skinManager.Import(new ImportTask(exportStream, "exported.osk")); imported.PerformRead(s => { - Assert.IsFalse(s.Protected); - Assert.AreNotEqual(originalSkinId, s.ID); - Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); + ClassicAssert.False(s.Protected); + ClassicAssert.AreNotEqual(originalSkinId, s.ID); + ClassicAssert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); }); }); @@ -274,21 +277,21 @@ public Task TestExportThenImportClassicSkin() => runSkinTest(async osu => await skinManager.CurrentSkinInfo.Value.PerformRead(async s => { - Assert.IsFalse(s.Protected); - Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); + ClassicAssert.False(s.Protected); + ClassicAssert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); await new LegacySkinExporter(osu.Dependencies.Get()).ExportToStreamAsync(skinManager.CurrentSkinInfo.Value, exportStream); - Assert.Greater(exportStream.Length, 0); + ClassicAssert.Greater(exportStream.Length, 0); }); var imported = await skinManager.Import(new ImportTask(exportStream, "exported.osk")); imported.PerformRead(s => { - Assert.IsFalse(s.Protected); - Assert.AreNotEqual(originalSkinId, s.ID); - Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); + ClassicAssert.False(s.Protected); + ClassicAssert.AreNotEqual(originalSkinId, s.ID); + ClassicAssert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); }); }); @@ -304,9 +307,9 @@ public async Task TestExternallyMountingWithSubDirectory() var osu = LoadOsuIntoHost(host); var zipStream = new MemoryStream(); - using var zip = ZipArchive.Create(); - zip.AddEntry("folder/test.png", new MemoryStream(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF })); - zip.SaveTo(zipStream); + using var zip = ZipArchive.CreateArchive(); + zip.AddEntry("folder/test.png", new MemoryStream(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }), true); + zip.SaveTo(zipStream, new ZipWriterOptions(CompressionType.Deflate)); var import = await loadSkinIntoOsu(osu, new ImportTask(zipStream, "test skin.osk")); @@ -353,9 +356,9 @@ public async Task TestExternallyMountingImportWithInvalidFilename() var osu = LoadOsuIntoHost(host); var zipStream = new MemoryStream(); - using var zip = ZipArchive.Create(); - zip.AddEntry("test?.png", new MemoryStream(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF })); - zip.SaveTo(zipStream); + using var zip = ZipArchive.CreateArchive(); + zip.AddEntry("test?.png", new MemoryStream(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }), true); + zip.SaveTo(zipStream, new ZipWriterOptions(CompressionType.Deflate)); var import = await loadSkinIntoOsu(osu, new ImportTask(zipStream, "test skin.osk")); @@ -419,26 +422,26 @@ private void assertImportedOnce(Live import1, Live import2) private MemoryStream createEmptyOsk() { var zipStream = new MemoryStream(); - using var zip = ZipArchive.Create(); - zip.SaveTo(zipStream); + using var zip = ZipArchive.CreateArchive(); + zip.SaveTo(zipStream, new ZipWriterOptions(CompressionType.Deflate)); return zipStream; } private MemoryStream createOskWithNonIniFile() { var zipStream = new MemoryStream(); - using var zip = ZipArchive.Create(); - zip.AddEntry("hitcircle.png", new MemoryStream(new byte[] { 0, 1, 2, 3 })); - zip.SaveTo(zipStream); + using var zip = ZipArchive.CreateArchive(); + zip.AddEntry("hitcircle.png", new MemoryStream(new byte[] { 0, 1, 2, 3 }), true); + zip.SaveTo(zipStream, new ZipWriterOptions(CompressionType.Deflate)); return zipStream; } private MemoryStream createOskWithIni(string name, string author, bool makeUnique = false, string iniFilename = @"skin.ini", bool includeSectionHeader = true) { var zipStream = new MemoryStream(); - using var zip = ZipArchive.Create(); - zip.AddEntry(iniFilename, generateSkinIni(name, author, makeUnique, includeSectionHeader)); - zip.SaveTo(zipStream); + using var zip = ZipArchive.CreateArchive(); + zip.AddEntry(iniFilename, generateSkinIni(name, author, makeUnique, includeSectionHeader), true); + zip.SaveTo(zipStream, new ZipWriterOptions(CompressionType.Deflate)); return zipStream; } diff --git a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs index 9466fdf888ad..4bf685fc9fc4 100644 --- a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs +++ b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.IO; using osu.Game.Skinning; using osu.Game.Tests.Resources; @@ -32,9 +33,9 @@ public void TestDecodeSkinColours() new Color4(100, 100, 100, 255), // alpha is specified as 100, but should be ignored. }; - Assert.AreEqual(expectedColors.Count, comboColors?.Count); + ClassicAssert.AreEqual(expectedColors.Count, comboColors?.Count); for (int i = 0; i < expectedColors.Count; i++) - Assert.AreEqual(expectedColors[i], comboColors[i]); + ClassicAssert.AreEqual(expectedColors[i], comboColors![i]); } } @@ -49,9 +50,9 @@ public void TestDecodeEmptySkinColours() var comboColors = decoder.Decode(stream).ComboColours; var expectedColors = SkinConfiguration.DefaultComboColours; - Assert.AreEqual(expectedColors.Count, comboColors?.Count); + ClassicAssert.AreEqual(expectedColors.Count, comboColors?.Count); for (int i = 0; i < expectedColors.Count; i++) - Assert.AreEqual(expectedColors[i], comboColors[i]); + ClassicAssert.AreEqual(expectedColors[i], comboColors![i]); } } @@ -65,7 +66,7 @@ public void TestDecodeEmptySkinColoursNoFallback() { var skinConfiguration = decoder.Decode(stream); skinConfiguration.AllowDefaultComboColoursFallback = false; - Assert.IsNull(skinConfiguration.ComboColours); + ClassicAssert.Null(skinConfiguration.ComboColours); } } @@ -79,8 +80,8 @@ public void TestDecodeGeneral() { var config = decoder.Decode(stream); - Assert.AreEqual("test skin", config.SkinInfo.Name); - Assert.AreEqual("TestValue", config.ConfigDictionary["TestLookup"]); + ClassicAssert.AreEqual("test skin", config.SkinInfo.Name); + ClassicAssert.AreEqual("TestValue", config.ConfigDictionary["TestLookup"]); } } @@ -90,7 +91,7 @@ public void TestDecodeSpecifiedVersion() var decoder = new LegacySkinDecoder(); using (var resStream = TestResources.OpenResource("skin-20.ini")) using (var stream = new LineBufferedReader(resStream)) - Assert.AreEqual(2.0m, decoder.Decode(stream).LegacyVersion); + ClassicAssert.AreEqual(2.0m, decoder.Decode(stream).LegacyVersion); } [Test] @@ -99,7 +100,7 @@ public void TestStripWhitespace() var decoder = new LegacySkinDecoder(); using (var resStream = TestResources.OpenResource("skin-with-space.ini")) using (var stream = new LineBufferedReader(resStream)) - Assert.AreEqual(2.0m, decoder.Decode(stream).LegacyVersion); + ClassicAssert.AreEqual(2.0m, decoder.Decode(stream).LegacyVersion); } [Test] @@ -108,7 +109,7 @@ public void TestDecodeLatestVersion() var decoder = new LegacySkinDecoder(); using (var resStream = TestResources.OpenResource("skin-latest.ini")) using (var stream = new LineBufferedReader(resStream)) - Assert.AreEqual(SkinConfiguration.LATEST_VERSION, decoder.Decode(stream).LegacyVersion); + ClassicAssert.AreEqual(SkinConfiguration.LATEST_VERSION, decoder.Decode(stream).LegacyVersion); } [Test] diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index a493f334fdfe..995d4cf65f6c 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -79,6 +79,8 @@ public class SkinDeserialisationTest "Archives/modified-argon-20250424.osk", // Covers "Argon" unstable rate counter "Archives/modified-argon-20250809.osk", + // Covers legacy style performance points counter + "Archives/modified-classic-20250827.osk", // Covers "Argon" judgement counter "Archives/modified-argon-20250308.osk", }; diff --git a/osu.Game.Tests/Skins/TestSceneSkinResources.cs b/osu.Game.Tests/Skins/TestSceneSkinResources.cs index e77affd81706..17e9c9b65411 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinResources.cs @@ -66,7 +66,7 @@ public void TestSampleRetrievalOrder() mockResourceStore = new Mock>(); mockResourceStore.Setup(r => r.Get(It.IsAny())) .Callback(n => lookedUpFileNames.Add(n)) - .Returns(null); + .Returns(null!); }); AddStep("query sample", () => diff --git a/osu.Game.Tests/Utils/NamingUtilsTest.cs b/osu.Game.Tests/Utils/NamingUtilsTest.cs index 1f7e06f9965d..7e85171bb4c9 100644 --- a/osu.Game.Tests/Utils/NamingUtilsTest.cs +++ b/osu.Game.Tests/Utils/NamingUtilsTest.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Utils; namespace osu.Game.Tests.Utils @@ -15,7 +16,7 @@ public void TestNextBestNameEmptySet() { string nextBestName = NamingUtils.GetNextBestName(Enumerable.Empty(), "New Difficulty"); - Assert.AreEqual("New Difficulty", nextBestName); + ClassicAssert.AreEqual("New Difficulty", nextBestName); } [Test] @@ -30,7 +31,7 @@ public void TestNextBestNameNotTaken() string nextBestName = NamingUtils.GetNextBestName(existingNames, "New Difficulty"); - Assert.AreEqual("New Difficulty", nextBestName); + ClassicAssert.AreEqual("New Difficulty", nextBestName); } [Test] @@ -45,7 +46,7 @@ public void TestNextBestNameNotTakenButClose() string nextBestName = NamingUtils.GetNextBestName(existingNames, "New Difficulty"); - Assert.AreEqual("New Difficulty", nextBestName); + ClassicAssert.AreEqual("New Difficulty", nextBestName); } [Test] @@ -58,7 +59,7 @@ public void TestNextBestNameAlreadyTaken() string nextBestName = NamingUtils.GetNextBestName(existingNames, "New Difficulty"); - Assert.AreEqual("New Difficulty (1)", nextBestName); + ClassicAssert.AreEqual("New Difficulty (1)", nextBestName); } [Test] @@ -71,7 +72,7 @@ public void TestNextBestNameAlreadyTakenWithDifferentCase() string nextBestName = NamingUtils.GetNextBestName(existingNames, "New Difficulty"); - Assert.AreEqual("New Difficulty (1)", nextBestName); + ClassicAssert.AreEqual("New Difficulty (1)", nextBestName); } [Test] @@ -84,7 +85,7 @@ public void TestNextBestNameAlreadyTakenWithBrackets() string nextBestName = NamingUtils.GetNextBestName(existingNames, "New Difficulty (copy)"); - Assert.AreEqual("New Difficulty (copy) (1)", nextBestName); + ClassicAssert.AreEqual("New Difficulty (copy) (1)", nextBestName); } [Test] @@ -100,7 +101,7 @@ public void TestNextBestNameMultipleAlreadyTaken() string nextBestName = NamingUtils.GetNextBestName(existingNames, "New Difficulty"); - Assert.AreEqual("New Difficulty (4)", nextBestName); + ClassicAssert.AreEqual("New Difficulty (4)", nextBestName); } [Test] @@ -110,7 +111,7 @@ public void TestNextBestNameEvenMoreAlreadyTaken() string nextBestName = NamingUtils.GetNextBestName(existingNames, "New Difficulty"); - Assert.AreEqual("New Difficulty (31)", nextBestName); + ClassicAssert.AreEqual("New Difficulty (31)", nextBestName); } [Test] @@ -126,7 +127,7 @@ public void TestNextBestNameMultipleAlreadyTakenWithGaps() string nextBestName = NamingUtils.GetNextBestName(existingNames, "New Difficulty"); - Assert.AreEqual("New Difficulty (2)", nextBestName); + ClassicAssert.AreEqual("New Difficulty (2)", nextBestName); } [Test] @@ -134,7 +135,7 @@ public void TestNextBestFilenameEmptySet() { string nextBestFilename = NamingUtils.GetNextBestFilename(Enumerable.Empty(), "test_file.osr"); - Assert.AreEqual("test_file.osr", nextBestFilename); + ClassicAssert.AreEqual("test_file.osr", nextBestFilename); } [Test] @@ -149,7 +150,7 @@ public void TestNextBestFilenameNotTaken() string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "test_file.osr"); - Assert.AreEqual("test_file.osr", nextBestFilename); + ClassicAssert.AreEqual("test_file.osr", nextBestFilename); } [Test] @@ -164,7 +165,7 @@ public void TestNextBestFilenameNotTakenButClose() string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); - Assert.AreEqual("replay_file.osr", nextBestFilename); + ClassicAssert.AreEqual("replay_file.osr", nextBestFilename); } [Test] @@ -177,7 +178,7 @@ public void TestNextBestFilenameAlreadyTaken() string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); - Assert.AreEqual("replay_file (1).osr", nextBestFilename); + ClassicAssert.AreEqual("replay_file (1).osr", nextBestFilename); } [Test] @@ -191,7 +192,7 @@ public void TestNextBestFilenameAlreadyTakenDifferentCase() }; string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); - Assert.AreEqual("replay_file (3).osr", nextBestFilename); + ClassicAssert.AreEqual("replay_file (3).osr", nextBestFilename); } [Test] @@ -204,10 +205,10 @@ public void TestNextBestFilenameAlreadyTakenWithBrackets() }; string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); - Assert.AreEqual("replay_file (1).osr", nextBestFilename); + ClassicAssert.AreEqual("replay_file (1).osr", nextBestFilename); nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file (copy).osr"); - Assert.AreEqual("replay_file (copy) (1).osr", nextBestFilename); + ClassicAssert.AreEqual("replay_file (copy) (1).osr", nextBestFilename); } [Test] @@ -223,7 +224,7 @@ public void TestNextBestFilenameMultipleAlreadyTaken() string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); - Assert.AreEqual("replay_file (4).osr", nextBestFilename); + ClassicAssert.AreEqual("replay_file (4).osr", nextBestFilename); } [Test] @@ -240,7 +241,7 @@ public void TestNextBestFilenameMultipleAlreadyTakenWithGaps() string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); - Assert.AreEqual("replay_file (3).osr", nextBestFilename); + ClassicAssert.AreEqual("replay_file (3).osr", nextBestFilename); } [Test] @@ -254,10 +255,10 @@ public void TestNextBestFilenameNoExtensions() }; string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "surely"); - Assert.AreEqual("surely", nextBestFilename); + ClassicAssert.AreEqual("surely", nextBestFilename); nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "those"); - Assert.AreEqual("those (1)", nextBestFilename); + ClassicAssert.AreEqual("those (1)", nextBestFilename); } [Test] @@ -271,10 +272,10 @@ public void TestNextBestFilenameDifferentExtensions() }; string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); - Assert.AreEqual("replay_file (2).osr", nextBestFilename); + ClassicAssert.AreEqual("replay_file (2).osr", nextBestFilename); nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.txt"); - Assert.AreEqual("replay_file (1).txt", nextBestFilename); + ClassicAssert.AreEqual("replay_file (1).txt", nextBestFilename); } } } diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 3021589cdb86..3b531e203610 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -32,7 +32,7 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Storyboards.Drawables; using osu.Game.Tests.Resources; using osuTK; @@ -266,7 +266,11 @@ public void TestTransition() FadeAccessibleResults results = null; - AddStep("Transition to Results", () => player.Push(results = new FadeAccessibleResults(TestResources.CreateTestScoreInfo()))); + AddStep("Transition to Results", () => + { + player.ValidForResume = false; + player.Push(results = new FadeAccessibleResults(TestResources.CreateTestScoreInfo())); + }); AddUntilStep("Wait for results is current", () => results.IsCurrentScreen()); diff --git a/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs b/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs index 7f07563dfd4f..8ad7bf47b4e6 100644 --- a/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs +++ b/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs @@ -7,7 +7,9 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -31,7 +33,7 @@ public void TestColours() AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(5f), - ChildrenEnumerable = Enumerable.Range(0, 10).Select(i => new FillFlowContainer + ChildrenEnumerable = Enumerable.Range(0, 15).Select(i => new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -40,7 +42,9 @@ public void TestColours() Spacing = new Vector2(10f), ChildrenEnumerable = Enumerable.Range(0, 10).Select(j => { - var colour = colours.ForStarDifficulty(1f * i + 0.1f * j); + float difficulty = 1f * i + 0.1f * j; + var colour = colours.ForStarDifficulty(difficulty); + var textColour = colours.ForStarDifficultyText(difficulty); return new FillFlowContainer { @@ -48,36 +52,27 @@ public void TestColours() Origin = Anchor.Centre, AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, - Spacing = new Vector2(0f, 10f), + Spacing = new Vector2(0f, 5f), Children = new Drawable[] { - new CircularContainer + new OsuSpriteText { - Masking = true, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Size = new Vector2(75f, 25f), - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colour, - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = OsuColour.ForegroundTextColourFor(colour), - Text = colour.ToHex(), - }, - } + Font = FontUsage.Default.With(size: 10), + Text = $"BG: {colour.ToHex()}", }, new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = $"*{(1f * i + 0.1f * j):0.00}", + Font = FontUsage.Default.With(size: 10), + Text = $"Text: {textColour.ToHex()}", + }, + new StarRatingDisplay(new StarDifficulty(difficulty, 0)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, } } }; diff --git a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs index b334616125b2..3cce3782477d 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs @@ -220,10 +220,13 @@ private void load() protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); if (registerAsOwner) - dependencies.CacheAs(this); - return dependencies; + { + // Automatically handled by interface caching. + return base.CreateChildDependencies(parent); + } + + return new DependencyContainer(); } } diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs index f1422b46548e..bf88cee11bae 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs @@ -15,7 +15,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.Metadata; using osu.Game.Tests.Visual.OnlinePlay; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs index f83d424d566f..a47fb50c3cbd 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs @@ -1,45 +1,64 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using System; +using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Testing; +using osu.Game.Overlays; +using osu.Game.Screens; using osu.Game.Screens.Edit.Submission; -using osu.Game.Screens.Footer; namespace osu.Game.Tests.Visual.Editing { - public partial class TestSceneBeatmapSubmissionOverlay : OsuTestScene + public partial class TestSceneBeatmapSubmissionOverlay : ScreenTestScene { - private ScreenFooter footer = null!; + private TestBeatmapSubmissionOverlayScreen screen = null!; + + [Cached] + private readonly BeatmapSubmissionSettings beatmapSubmissionSettings = new BeatmapSubmissionSettings(); [SetUpSteps] - public void SetUpSteps() + public override void SetUpSteps() { - AddStep("add overlay", () => + base.SetUpSteps(); + + AddStep("push screen", () => LoadScreen(screen = new TestBeatmapSubmissionOverlayScreen())); + AddUntilStep("wait until screen is loaded", () => screen.IsLoaded, () => Is.True); + AddStep("show overlay", () => screen.Overlay.Show()); + } + + private partial class TestBeatmapSubmissionOverlayScreen : OsuScreen + { + public override bool ShowFooter => true; + + public BeatmapSubmissionOverlay Overlay = null!; + + private IDisposable? overlayRegistration; + + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + [BackgroundDependencyLoader] + private void load() + { + LoadComponent(Overlay = new BeatmapSubmissionOverlay()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + overlayRegistration = overlayManager?.RegisterBlockingOverlay(Overlay); + } + + protected override void Dispose(bool isDisposing) { - var receptor = new ScreenFooter.BackReceptor(); - footer = new ScreenFooter(receptor); - - Child = new DependencyProvidingContainer - { - RelativeSizeAxes = Axes.Both, - CachedDependencies = new[] - { - (typeof(ScreenFooter), (object)footer), - (typeof(BeatmapSubmissionSettings), new BeatmapSubmissionSettings()), - }, - Children = new Drawable[] - { - receptor, - new BeatmapSubmissionOverlay - { - State = { Value = Visibility.Visible, }, - }, - footer, - } - }; - }); + base.Dispose(isDisposing); + overlayRegistration?.Dispose(); + } } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs index 6a9ca1292cf3..df5b58f31063 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; @@ -662,11 +663,11 @@ public void TestShiftModifierMaintainsAspectRatio() AddStep("move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(50, 0))); - AddStep("aspect ratio does not equal", () => Assert.AreNotEqual(aspectRatioBeforeDrag, getAspectRatio())); + AddStep("aspect ratio does not equal", () => ClassicAssert.AreNotEqual(aspectRatioBeforeDrag, getAspectRatio())); AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); - AddStep("aspect ratio does equal", () => Assert.AreEqual(aspectRatioBeforeDrag, getAspectRatio())); + AddStep("aspect ratio does equal", () => ClassicAssert.AreEqual(aspectRatioBeforeDrag, getAspectRatio())); AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); @@ -701,11 +702,11 @@ public void TestAltModifierScalesAroundCenter() AddStep("move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(50, 0))); - AddStep("center does not equal", () => Assert.AreNotEqual(centerBeforeDrag, getCenter())); + AddStep("center does not equal", () => ClassicAssert.AreNotEqual(centerBeforeDrag, getCenter())); AddStep("press alt", () => InputManager.PressKey(Key.AltLeft)); - AddStep("center does equal", () => Assert.AreEqual(centerBeforeDrag, getCenter())); + AddStep("center does equal", () => ClassicAssert.AreEqual(centerBeforeDrag, getCenter())); AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); @@ -745,19 +746,19 @@ public void TestShiftAndAltModifierKeys() AddStep("move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(50, 0))); - AddStep("aspect ratio does not equal", () => Assert.AreNotEqual(aspectRatioBeforeDrag, getAspectRatio())); + AddStep("aspect ratio does not equal", () => ClassicAssert.AreNotEqual(aspectRatioBeforeDrag, getAspectRatio())); - AddStep("center does not equal", () => Assert.AreNotEqual(centerBeforeDrag, getCenter())); + AddStep("center does not equal", () => ClassicAssert.AreNotEqual(centerBeforeDrag, getCenter())); AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); - AddStep("aspect ratio does equal", () => Assert.AreEqual(aspectRatioBeforeDrag, getAspectRatio())); + AddStep("aspect ratio does equal", () => ClassicAssert.AreEqual(aspectRatioBeforeDrag, getAspectRatio())); - AddStep("center does not equal", () => Assert.AreNotEqual(centerBeforeDrag, getCenter())); + AddStep("center does not equal", () => ClassicAssert.AreNotEqual(centerBeforeDrag, getCenter())); AddStep("press alt", () => InputManager.PressKey(Key.AltLeft)); - AddStep("center does equal", () => Assert.AreEqual(centerBeforeDrag, getCenter())); + AddStep("center does equal", () => ClassicAssert.AreEqual(centerBeforeDrag, getCenter())); AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index 8d7eb41369f3..ed91fe848ab3 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -132,6 +132,7 @@ public void TestCreateNewDifficulty([Values] bool sameRuleset) AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); AddStep("add effect point", () => EditorBeatmap.ControlPointInfo.Add(500, new EffectControlPoint { KiaiMode = true })); + AddStep("add bookmarks", () => EditorBeatmap.Bookmarks.AddRange([500, 1000])); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { new HitCircle @@ -185,6 +186,7 @@ public void TestCreateNewDifficulty([Values] bool sameRuleset) var effectPoint = EditorBeatmap.ControlPointInfo.EffectPoints.Single(); return effectPoint.Time == 500 && effectPoint.KiaiMode && effectPoint.ScrollSpeedBindable.IsDefault; }); + AddAssert("created difficulty has bookmarks", () => EditorBeatmap.Bookmarks.Count == 2); AddAssert("created difficulty has no objects", () => EditorBeatmap.HitObjects.Count == 0); AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); @@ -223,6 +225,7 @@ public void TestCreateNewDifficultyWithScrollSpeed_SameRuleset() AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = previousDifficultyName = Guid.NewGuid().ToString()); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add bookmarks", () => EditorBeatmap.Bookmarks.AddRange([500, 1000])); AddStep("add effect points", () => { EditorBeatmap.ControlPointInfo.Add(250, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.05 }); @@ -253,6 +256,8 @@ public void TestCreateNewDifficultyWithScrollSpeed_SameRuleset() return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); + AddAssert("created difficulty has bookmarks", () => EditorBeatmap.Bookmarks.Count == 2); + AddAssert("created difficulty has effect points", () => { return EditorBeatmap.ControlPointInfo.EffectPoints.SequenceEqual(new[] @@ -284,6 +289,7 @@ public void TestCreateNewDifficultyWithScrollSpeed_DifferentRuleset() AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add bookmarks", () => EditorBeatmap.Bookmarks.AddRange([500, 1000])); AddStep("add effect points", () => { EditorBeatmap.ControlPointInfo.Add(250, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.05 }); @@ -311,6 +317,8 @@ public void TestCreateNewDifficultyWithScrollSpeed_DifferentRuleset() return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); + AddAssert("created difficulty has bookmarks", () => EditorBeatmap.Bookmarks.Count == 2); + AddAssert("created difficulty has effect points", () => { // since this difficulty is on another ruleset, scroll speed specifications are completely reset, @@ -344,6 +352,7 @@ public void TestCopyDifficulty() StartTime = 1000 } })); + AddStep("add bookmarks", () => EditorBeatmap.Bookmarks.AddRange([500, 1000])); AddStep("set approach rate", () => EditorBeatmap.Difficulty.ApproachRate = 4); AddStep("set combo colours", () => { @@ -394,6 +403,7 @@ public void TestCopyDifficulty() return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); AddAssert("created difficulty has objects", () => EditorBeatmap.HitObjects.Count == 2); + AddAssert("created difficulty has bookmarks", () => EditorBeatmap.Bookmarks.Count == 2); AddAssert("approach rate correctly copied", () => EditorBeatmap.Difficulty.ApproachRate == 4); AddAssert("combo colours correctly copied", () => EditorBeatmap.BeatmapSkin.AsNonNull().ComboColours.Count == 2); @@ -871,7 +881,7 @@ private bool setFile(string archivePath, Func func) try { - using (var zip = ZipArchive.Open(temp)) + using (var zip = ZipArchive.OpenArchive(temp)) zip.WriteToDirectory(extractedFolder); return func(extractedFolder); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index 7f40da5babef..4ab757163695 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -15,7 +15,7 @@ using osu.Game.Overlays; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing @@ -134,7 +134,7 @@ public void TestLengthAndStarRatingUpdated() double lastStarRating = 0; double lastLength = 0; - AddStep("Add timing point", () => EditorBeatmap.ControlPointInfo.Add(200, new TimingControlPoint { BeatLength = 600 })); + AddStep("Add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 600 })); AddStep("Change to placement mode", () => InputManager.Key(Key.Number2)); AddStep("Move to playfield", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre)); AddStep("Place single hitcircle", () => InputManager.Click(MouseButton.Left)); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneFormSampleSet.cs b/osu.Game.Tests/Visual/Editing/TestSceneFormSampleSet.cs new file mode 100644 index 000000000000..cc6b4b05d231 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneFormSampleSet.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Game.Graphics.Cursor; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components; +using osu.Game.Tests.Visual.UserInterface; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneFormSampleSet : ThemeComparisonTestScene + { + public TestSceneFormSampleSet() + : base(false) + { + } + + protected override Drawable CreateContent() => new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FormSampleSet + { + Current = + { + Value = new EditorBeatmapSkin.SampleSet(3, "Custom set #3") + { + Filenames = ["normal-hitwhistle3.wav"] + } + }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.4f, + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs index 765fe1ecf60e..6b18aac478d5 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using Humanizer; using NUnit.Framework; -using osu.Framework.Input; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Audio; @@ -20,6 +19,7 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Components.TernaryButtons; +using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Timing; using osu.Game.Tests.Beatmaps; @@ -113,6 +113,77 @@ public void TestSingleSelection() hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); } + [Test] + public void TestAutoAdditionsBankMatchesNormalBankWhenChangedViaPopover() + { + clickSamplePiece(0); + setBankViaPopover(HitSampleInfo.BANK_SOFT); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + + toggleAdditionViaPopover(1); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + + setBankViaPopover(HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + + setAdditionBankViaPopover(HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + + setAdditionBankViaPopover(EditorSelectionHandler.HIT_BANK_AUTO); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + } + + [Test] + public void TestAutoAdditionsBankMatchesNormalBankWhenChangedViaHotkeys() + { + AddStep("select first object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0])); + AddStep("set soft normal bank", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.E); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + + AddStep("toggle finish", () => InputManager.Key(Key.E)); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + + AddStep("set drum normal bank", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + + AddStep("set normal addition bank", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.AltLeft); + }); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + + AddStep("set auto addition bank", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.AltLeft); + }); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + } + [Test] public void TestUndo() { @@ -196,11 +267,6 @@ public void TestPopoverMultipleSelectionWithSameSampleBank() clickSamplePiece(1); samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); - setBankViaPopover(string.Empty); - hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); - hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); - samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); - setBankViaPopover(HitSampleInfo.BANK_DRUM); hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); @@ -219,11 +285,6 @@ public void TestPopoverMultipleSelectionWithDifferentSampleBank() clickSamplePiece(1); samplePopoverHasIndeterminateBank(); - setBankViaPopover(string.Empty); - hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); - hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); - samplePopoverHasIndeterminateBank(); - setBankViaPopover(HitSampleInfo.BANK_NORMAL); hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); hitObjectHasSampleBank(1, HitSampleInfo.BANK_NORMAL); @@ -357,7 +418,7 @@ public void TestHotkeysMultipleSelectionWithSameSampleBank() { for (int i = 0; i < h.Samples.Count; i++) { - h.Samples[i] = h.Samples[i].With(newBank: HitSampleInfo.BANK_SOFT); + h.Samples[i] = h.Samples[i].With(newBank: HitSampleInfo.BANK_SOFT, newEditorAutoBank: false); } } }); @@ -365,7 +426,7 @@ public void TestHotkeysMultipleSelectionWithSameSampleBank() AddStep("add whistle addition", () => { foreach (var h in EditorBeatmap.HitObjects) - h.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT)); + h.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT, editorAutoBank: false)); }); AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); @@ -534,6 +595,172 @@ void checkPlacementSampleAdditionBank(string expected) => AddAssert($"Placement () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name != HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(expected)); } + [Test] + public void TestNonAutoBankHotkeysDuringPlacementPersistAfterPlacement() + { + AddStep("Clear all objects", () => EditorBeatmap.Clear()); + AddStep("Enter placement mode", () => InputManager.Key(Key.Number2)); + AddStep("Move mouse to centre", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre)); + + AddStep("Move to 3000", () => EditorClock.Seek(3000)); + + AddStep("Press drum bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + AddAssert($"Placement sample is {HitSampleInfo.BANK_DRUM}", + () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name == HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(HitSampleInfo.BANK_DRUM)); + + AddStep("Press normal addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.AltLeft); + }); + + AddStep("Press finish sample shortcut", () => + { + InputManager.Key(Key.E); + }); + + AddAssert($"Placement sample addition is {HitSampleInfo.BANK_NORMAL}", + () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name != HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(HitSampleInfo.BANK_NORMAL)); + + AddStep("Finish placement", () => InputManager.Click(MouseButton.Left)); + + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasAutoNormalBankFlag(0, false); + hitObjectHasAutoAdditionBankFlag(0, false); + + clickSamplePiece(0); + samplePopoverIsOpen(); + samplePopoverHasSingleAdditionBank(HitSampleInfo.BANK_NORMAL); + } + + [Test] + public void TestAutoAdditionBankHotkeyDuringPlacementPersistsAfterPlacement() + { + AddStep("Clear all objects", () => EditorBeatmap.Clear()); + AddStep("Enter placement mode", () => InputManager.Key(Key.Number2)); + AddStep("Move mouse to centre", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre)); + + AddStep("Move to 3000", () => EditorClock.Seek(3000)); + + AddStep("Press drum bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + AddAssert($"Placement sample is {HitSampleInfo.BANK_DRUM}", + () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name == HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(HitSampleInfo.BANK_DRUM)); + + AddStep("Press normal addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.AltLeft); + }); + + AddStep("Press finish sample shortcut", () => + { + InputManager.Key(Key.E); + }); + + AddStep("Press auto addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.AltLeft); + }); + + AddAssert($"Placement sample addition is {HitSampleInfo.BANK_DRUM}", + () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name != HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(HitSampleInfo.BANK_DRUM)); + + AddStep("Finish placement", () => InputManager.Click(MouseButton.Left)); + + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasAutoNormalBankFlag(0, false); + hitObjectHasAutoAdditionBankFlag(0, true); + + clickSamplePiece(0); + samplePopoverIsOpen(); + samplePopoverHasSingleAdditionBank(EditorSelectionHandler.HIT_BANK_AUTO); + } + + [Test] + public void TestFullAutoBankHotkeyDuringPlacementPersistsAfterPlacement() + { + AddStep("Clear all objects", () => EditorBeatmap.Clear()); + AddStep("Enter placement mode", () => InputManager.Key(Key.Number2)); + AddStep("Move mouse to centre", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre)); + + AddStep("Move to 3000", () => EditorClock.Seek(3000)); + + AddStep("Press auto normal bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + AddAssert($"Placement sample is {HitSampleInfo.BANK_NORMAL}", + () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name == HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(HitSampleInfo.BANK_NORMAL)); + + AddStep("Press finish sample shortcut", () => + { + InputManager.Key(Key.E); + }); + + AddStep("Press auto addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.AltLeft); + }); + + AddAssert($"Placement sample addition is {HitSampleInfo.BANK_NORMAL}", + () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name != HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(HitSampleInfo.BANK_NORMAL)); + + AddStep("Finish placement", () => InputManager.Click(MouseButton.Left)); + + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasAutoNormalBankFlag(0, false); // it's the first object - nothing to inherit bank from + hitObjectHasAutoAdditionBankFlag(0, true); + + clickSamplePiece(0); + samplePopoverIsOpen(); + samplePopoverHasSingleBank(HitSampleInfo.BANK_NORMAL); + samplePopoverHasSingleAdditionBank(EditorSelectionHandler.HIT_BANK_AUTO); + dismissPopover(); + + AddStep("Move to 5000", () => EditorClock.Seek(5000)); + AddStep("Enter placement mode", () => InputManager.Key(Key.Number2)); + AddStep("Move mouse to centre", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre)); + AddStep("Finish placement", () => InputManager.Click(MouseButton.Left)); + + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); // finish is still implied, continuing from first placement + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_NORMAL); + hitObjectHasAutoNormalBankFlag(1, true); + hitObjectHasAutoAdditionBankFlag(1, true); + + clickSamplePiece(1); + samplePopoverIsOpen(); + samplePopoverHasSingleBank(HitSampleInfo.BANK_NORMAL); + samplePopoverHasSingleAdditionBank(EditorSelectionHandler.HIT_BANK_AUTO); + } + [Test] public void PopoverForMultipleSelectionChangesAllSamples() { @@ -610,19 +837,19 @@ public void TestHotkeysAffectNodeSamples() Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero), new PathControlPoint(new Vector2(250, 0)) }), Samples = { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, editorAutoBank: false) }, NodeSamples = new List> { new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_DRUM), - new HitSampleInfo(HitSampleInfo.HIT_CLAP, bank: HitSampleInfo.BANK_DRUM), + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_DRUM, editorAutoBank: false), + new HitSampleInfo(HitSampleInfo.HIT_CLAP, bank: HitSampleInfo.BANK_DRUM, editorAutoBank: false), }, new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_SOFT), - new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, bank: HitSampleInfo.BANK_SOFT), + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_SOFT, editorAutoBank: false), + new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, bank: HitSampleInfo.BANK_SOFT, editorAutoBank: false), }, } }); @@ -819,6 +1046,174 @@ void assertNoChanges() } } + [Test] + public void TestAddSoundBeforeSettingNonAutoAdditionBankOnSelectedObject() + { + AddStep("select first object", () => + { + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0]); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + + AddStep("add finish sound", () => InputManager.Key(Key.E)); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasAutoAdditionBankFlag(0, true); + + AddStep("set drum addition bank", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.AltLeft); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasAutoAdditionBankFlag(0, false); + } + + [Test] + public void TestAddSoundAfterSettingNonAutoAdditionBankOnSelectedObject() + { + AddStep("select first object", () => + { + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0]); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + + AddStep("set drum addition bank", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.AltLeft); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + + AddStep("add finish sound", () => InputManager.Key(Key.E)); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasAutoAdditionBankFlag(0, false); + } + + [Test] + public void TestSwitchSoundAfterSettingNonAutoAdditionBankOnSelectedObject() + { + AddStep("select first object", () => + { + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0]); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + + AddStep("set drum addition bank", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.AltLeft); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + + AddStep("add finish sound", () => InputManager.Key(Key.E)); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasAutoAdditionBankFlag(0, false); + + AddStep("remove finish sound", () => InputManager.Key(Key.E)); + AddStep("add whistle sound", () => InputManager.Key(Key.W)); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasAutoAdditionBankFlag(0, false); + } + + [Test] + public void TestAddSoundBeforeSettingAutoAdditionBankOnSelectedObject() + { + AddStep("select first object", () => + { + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0]); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + + AddStep("add finish sound", () => InputManager.Key(Key.E)); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasAutoAdditionBankFlag(0, true); + + AddStep("set auto addition bank", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.AltLeft); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasAutoAdditionBankFlag(0, true); + + AddStep("set drum normal bank", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasAutoAdditionBankFlag(0, true); + } + + [Test] + public void TestAddSoundAfterSettingAutoAdditionBankOnSelectedObject() + { + AddStep("select first object", () => + { + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0]); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + + AddStep("set auto addition bank", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.AltLeft); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + + AddStep("add finish sound", () => InputManager.Key(Key.E)); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasAutoAdditionBankFlag(0, true); + + AddStep("set drum normal bank", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasAutoAdditionBankFlag(0, true); + } + private void clickSamplePiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} sample piece", () => { var samplePiece = this.ChildrenOfType().Single(piece => piece is not NodeSamplePointPiece && piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); @@ -878,17 +1273,25 @@ private void samplePopoverHasIndeterminateVolume() => AddUntilStep("sample popov private void samplePopoverHasSingleBank(string bank) => AddUntilStep($"sample popover has bank {bank}", () => { var popover = this.ChildrenOfType().SingleOrDefault(); - var textBox = popover?.ChildrenOfType().First(); + var dropdown = popover?.ChildrenOfType>().First(); - return textBox?.Current.Value == bank && string.IsNullOrEmpty(textBox.PlaceholderText.ToString()); + return dropdown?.Current.Value == bank; }); private void samplePopoverHasIndeterminateBank() => AddUntilStep("sample popover has indeterminate bank", () => { var popover = this.ChildrenOfType().SingleOrDefault(); - var textBox = popover?.ChildrenOfType().First(); + var dropdown = popover?.ChildrenOfType>().First(); - return textBox != null && string.IsNullOrEmpty(textBox.Current.Value) && !string.IsNullOrEmpty(textBox.PlaceholderText.ToString()); + return dropdown?.Current.Value == "(multiple)"; + }); + + private void samplePopoverHasSingleAdditionBank(string bank) => AddUntilStep($"sample popover has bank {bank}", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + var dropdown = popover?.ChildrenOfType>().ElementAt(1); + + return dropdown?.Current.Value == bank; }); private void dismissPopover() @@ -920,23 +1323,15 @@ private void hitObjectNodeHasSampleVolume(int objectIndex, int nodeIndex, int vo private void setBankViaPopover(string bank) => AddStep($"set bank {bank} via popover", () => { var popover = this.ChildrenOfType().Single(); - var textBox = popover.ChildrenOfType().First(); + var textBox = popover.ChildrenOfType>().First(); textBox.Current.Value = bank; - // force a commit via keyboard. - // this is needed when testing attempting to set empty bank - which should revert to the previous value, but only on commit. - ((IFocusManager)InputManager).ChangeFocus(textBox); - InputManager.Key(Key.Enter); }); private void setAdditionBankViaPopover(string bank) => AddStep($"set addition bank {bank} via popover", () => { var popover = this.ChildrenOfType().Single(); - var textBox = popover.ChildrenOfType().ToArray()[1]; + var textBox = popover.ChildrenOfType>().ToArray()[1]; textBox.Current.Value = bank; - // force a commit via keyboard. - // this is needed when testing attempting to set empty bank - which should revert to the previous value, but only on commit. - ((IFocusManager)InputManager).ChangeFocus(textBox); - InputManager.Key(Key.Enter); }); private void toggleAdditionViaPopover(int index) => AddStep($"toggle addition {index} via popover", () => @@ -972,6 +1367,18 @@ private void hitObjectHasSampleAdditionBank(int objectIndex, string bank) => Add return h.Samples.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); }); + private void hitObjectHasAutoNormalBankFlag(int objectIndex, bool autoBank) => AddAssert($"{objectIndex.ToOrdinalWords()} has auto normal bank {(autoBank ? "on" : "off")}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(o => o.EditorAutoBank == autoBank); + }); + + private void hitObjectHasAutoAdditionBankFlag(int objectIndex, bool autoBank) => AddAssert($"{objectIndex.ToOrdinalWords()} has auto addition bank {(autoBank ? "on" : "off")}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.Samples.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(o => o.EditorAutoBank == autoBank); + }); + private void hitObjectNodeHasSamples(int objectIndex, int nodeIndex, params string[] samples) => AddAssert( $"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has samples {string.Join(',', samples)}", () => { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs index 636b3f54d80e..7ded3467c6e7 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs @@ -34,7 +34,7 @@ public void TestLocallyModifyingOnlineBeatmap() SaveEditor(); ReloadEditorToSameBeatmap(); - AddAssert("beatmap marked as locally modified", () => EditorBeatmap.BeatmapInfo.Status, () => Is.EqualTo(BeatmapOnlineStatus.LocallyModified)); + AddUntilStep("beatmap marked as locally modified", () => EditorBeatmap.BeatmapInfo.Status, () => Is.EqualTo(BeatmapOnlineStatus.LocallyModified)); AddAssert("beatmap hash changed", () => EditorBeatmap.BeatmapInfo.MD5Hash, () => Is.Not.EqualTo(initialHash)); } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs index e3b79d40533b..46f47f6a928d 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs @@ -14,7 +14,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Menu; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Editing diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs index ae20f5e5cf59..31e2e5b30280 100644 --- a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs @@ -7,6 +7,7 @@ using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Rulesets; @@ -27,10 +28,51 @@ public partial class TestScenePlacementBlueprint : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new TestBeatmap(ruleset, false); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint()); + return beatmap; + } private GlobalActionContainer globalActionContainer => this.ChildrenOfType().Single(); + [Test] + public void TestPlaceThenUndo() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); + + AddStep("undo", () => Editor.Undo()); + + AddAssert("circle removed", () => EditorBeatmap.HitObjects, () => Is.Empty); + } + + [Test] + public void TestTimingLost() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + + AddAssert("placement ready", () => this.ChildrenOfType().Single().CurrentPlacement, () => Is.Not.Null); + + AddStep("nuke timing", () => EditorBeatmap.ControlPointInfo.Clear()); + + AddAssert("placement not available", () => this.ChildrenOfType().Single().CurrentPlacement, () => Is.Null); + + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + + AddAssert("placement not available", () => this.ChildrenOfType().Single().CurrentPlacement, () => Is.Null); + + AddStep("add back timing", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + + AddAssert("placement ready", () => this.ChildrenOfType().Single().CurrentPlacement, () => Is.Not.Null); + } + [Test] public void TestDeleteUsingMiddleMouse() { @@ -230,8 +272,8 @@ public void TestAutomaticBankAssignment() AddAssert("circle has 2 samples", () => EditorBeatmap.HitObjects[1].Samples, () => Has.Count.EqualTo(2)); AddAssert("normal sample has soft bank", () => EditorBeatmap.HitObjects[1].Samples.Single(s => s.Name == HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(HitSampleInfo.BANK_SOFT)); - AddAssert("clap sample has drum bank", () => EditorBeatmap.HitObjects[1].Samples.Single(s => s.Name == HitSampleInfo.HIT_CLAP).Bank, - () => Is.EqualTo(HitSampleInfo.BANK_DRUM)); + AddAssert("clap sample has soft bank", () => EditorBeatmap.HitObjects[1].Samples.Single(s => s.Name == HitSampleInfo.HIT_CLAP).Bank, + () => Is.EqualTo(HitSampleInfo.BANK_SOFT)); AddAssert("circle inherited volume", () => EditorBeatmap.HitObjects[1].Samples.All(s => s.Volume == 70)); AddStep("seek to 1000", () => EditorClock.Seek(1000)); // previous object is the one at time 500, which has no additions diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs index 229cb995d8be..1915c00eb458 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; @@ -27,7 +28,12 @@ public partial class TestSceneTimelineSelection : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new TestBeatmap(ruleset, false); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + return beatmap; + } private TimelineBlueprintContainer blueprintContainer => Editor.ChildrenOfType().First(); @@ -80,7 +86,7 @@ public void TestContextMenuWithObjectBehind() { InputManager.Key(Key.Number1); blueprint = this.ChildrenOfType().First(); - InputManager.MoveMouseTo(blueprint); + InputManager.MoveMouseTo(blueprint, new Vector2(-1, 0)); InputManager.Click(MouseButton.Left); }); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapMetadataDisplay.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs rename to osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapMetadataDisplay.cs index a9829fbf0c25..63a0198ef5ba 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapMetadataDisplay.cs @@ -19,7 +19,7 @@ using osu.Game.Screens.Play; using osuTK; -namespace osu.Game.Tests.Visual.SongSelect +namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneBeatmapMetadataDisplay : OsuTestScene { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableGameplayLeaderboardScore.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableGameplayLeaderboardScore.cs new file mode 100644 index 000000000000..e17f1349b316 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableGameplayLeaderboardScore.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.Leaderboards; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneDrawableGameplayLeaderboardScore : OsuTestScene + { + private readonly APIUser user = new APIUser { Username = "user" }; + private readonly BindableLong totalScore = new BindableLong(); + private readonly Bindable position = new Bindable(); + private readonly BindableBool quit = new BindableBool(); + private readonly BindableBool expanded = new BindableBool(); + + public TestSceneDrawableGameplayLeaderboardScore() + { + AddSliderStep("total score", 0, 1_000_000, 500_000, s => totalScore.Value = s); + AddSliderStep("position", 1, 100, 5, s => position.Value = s); + AddToggleStep("toggle quit", q => quit.Value = q); + AddToggleStep("toggle expanded", e => expanded.Value = e); + } + + private static readonly OsuColour osu_colour = new OsuColour(); + + private static readonly object?[][] leaderboard_variants = + { + new object?[] { false, null }, + new object?[] { true, null }, + new object?[] { false, osu_colour.TeamColourRed }, + new object?[] { true, osu_colour.TeamColourRed }, + new object?[] { false, osu_colour.TeamColourBlue }, + new object?[] { true, osu_colour.TeamColourBlue }, + }; + + [TestCaseSource(nameof(leaderboard_variants))] + public void TestVariants(bool tracked, Color4? teamColour) + { + AddStep("show", () => + { + GameplayLeaderboardScore score = new GameplayLeaderboardScore(user, tracked, totalScore) + { + Position = { BindTarget = position }, + HasQuit = { BindTarget = quit }, + TeamColour = teamColour, + }; + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Width = 250, + Child = new DrawableGameplayLeaderboardScore(score) + { + Expanded = { BindTarget = expanded }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs index 800857c97376..afbd9da73cba 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.IO.Stores; using osu.Framework.Testing; +using osu.Framework.Timing; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; @@ -50,6 +51,45 @@ public void TestSkinSpriteDisallowedByDefault() sprites.All(sprite => sprite.ChildrenOfType().All(s => s.Texture == null))); } + [Test] + public void TestSpriteFadeOverflowBehaviour() + { + ManualClock manualClock = new ManualClock(); + + AddStep("allow storyboard lookup", () => + { + storyboard.UseSkinSprites = false; + storyboard.ProvideResources = true; + }); + + AddStep("create sprite", () => SetContents(_ => + { + var layer = storyboard.GetLayer("Background"); + + var sprite = new StoryboardSprite(lookup_name, Anchor.TopLeft, new Vector2(256, 192)); + sprite.Commands.AddAlpha(Easing.None, 0, 2000, 0, 2); + + layer.Elements.Clear(); + layer.Add(sprite); + + return new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + storyboard.CreateDrawable().With(d => d.Clock = new FramedClock(manualClock)) + } + }; + })); + + AddStep("seek to 1000 ms", () => manualClock.CurrentTime = 900); + AddUntilStep("sprite reached high opacity once", () => sprites.All(sprite => sprite.ChildrenOfType().All(s => s.Alpha > 0.8f))); + AddStep("seek to 2000 ms", () => manualClock.CurrentTime = 1100); + AddUntilStep("sprite reset to low opacity", () => sprites.All(sprite => sprite.ChildrenOfType().All(s => s.Alpha < 0.2f))); + AddStep("seek to 2000 ms", () => manualClock.CurrentTime = 1900); + AddUntilStep("sprite reached high opacity twice", () => sprites.All(sprite => sprite.ChildrenOfType().All(s => s.Alpha > 0.8f))); + } + [Test] public void TestLookupFromStoryboard() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index 45e14053e0e0..764a8569e6d0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -21,7 +21,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Tests.Gameplay; using osuTK.Graphics; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index d51c9b3f8836..1e6e0007b555 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -270,7 +270,7 @@ private void createNew(Action? action = null) { AddStep("create overlay", () => { - hudOverlay = new HUDOverlay(null, Array.Empty()); + hudOverlay = new HUDOverlay(null, Array.Empty(), new PlayerConfiguration()); // Add any key just to display the key counter visually. hudOverlay.InputCountController.Add(new KeyCounterKeyboardTrigger(Key.Space)); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs index 47791dd46207..131b901d6f21 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs @@ -25,7 +25,7 @@ using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Scoring; using osu.Game.Screens.Play; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Skinning; using osu.Game.Tests.Gameplay; using osu.Game.Tests.Visual.Spectator; @@ -119,7 +119,7 @@ public void TestLayout( Children = new Drawable[] { drawableRuleset, - new HUDOverlay(drawableRuleset, []) + new HUDOverlay(drawableRuleset, [], new PlayerConfiguration()) { RelativeSizeAxes = Axes.Both, } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs index eada72326d98..68f314d52af7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs @@ -18,6 +18,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; using osu.Game.Skinning.Triangles; using osu.Game.Tests.Gameplay; @@ -35,7 +36,7 @@ public partial class TestScenePerformancePointsCounter : SkinnableHUDComponentTe protected override Drawable CreateDefaultImplementation() => new TrianglesPerformancePointsCounter(); protected override Drawable CreateArgonImplementation() => new ArgonPerformancePointsCounter(); - protected override Drawable CreateLegacyImplementation() => Empty(); + protected override Drawable CreateLegacyImplementation() => new LegacyPerformancePointsCounter(); private Bindable lastJudgementResult => (Bindable)gameplayState.LastJudgementResult; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index 97889eea4d34..da41349117d3 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Text; +using Newtonsoft.Json; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -150,7 +151,7 @@ public void TestCyclicSelection() { List blueprints = new List(); - AddStep("clear list", () => blueprints.Clear()); + AddStep("clear list", blueprints.Clear); for (int i = 0; i < 3; i++) { @@ -378,6 +379,23 @@ public void TestCopyPaste() () => Is.EqualTo(3)); } + [Test] + public void TestCopyPasteIdempotency() + { + string state = null!; + AddStep("select everything", () => InputManager.Keys(PlatformAction.SelectAll)); + AddStep("dump state", () => + { + state = JsonConvert.SerializeObject(skinEditor.SelectedComponents.Cast().Select(s => s.CreateSerialisedInfo()).ToArray()); + }); + AddStep("copy", () => InputManager.Keys(PlatformAction.Copy)); + AddStep("delete", () => InputManager.Keys(PlatformAction.Delete)); + AddStep("paste", () => InputManager.Keys(PlatformAction.Paste)); + AddAssert("pasted state equals dumped", + () => JsonConvert.SerializeObject(skinEditor.SelectedComponents.Cast().Select(s => s.CreateSerialisedInfo()).ToArray()), + () => Is.EqualTo(state)); + } + private SkinnableContainer globalHUDTarget => Player.ChildrenOfType() .Single(c => c.Lookup.Lookup == GlobalSkinnableContainers.MainHUDComponents && c.Lookup.Ruleset == null); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index 00369ade184f..824e4f6a1cb2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -15,7 +15,7 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Tests.Gameplay; using osuTK.Input; @@ -60,7 +60,7 @@ public void SetUpSteps() var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap, mods); - var hudOverlay = new HUDOverlay(drawableRuleset, mods) + var hudOverlay = new HUDOverlay(drawableRuleset, mods, new PlayerConfiguration()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 754ec841d852..e42e8f994e15 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -21,7 +21,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Skinning; using osu.Game.Tests.Gameplay; @@ -96,7 +96,7 @@ private void createNew(Action action = null) { SetContents(_ => { - hudOverlay = new HUDOverlay(new DrawableOsuRuleset(new OsuRuleset(), new OsuBeatmap()), Array.Empty()); + hudOverlay = new HUDOverlay(new DrawableOsuRuleset(new OsuRuleset(), new OsuBeatmap()), Array.Empty(), new PlayerConfiguration()); action?.Invoke(hudOverlay); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs index 276a0c3410b5..946b625608ba 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs @@ -173,7 +173,7 @@ public TestSkipOverlay(double startTime) public Drawable OverlayContent => InternalChild; - public Drawable FadingContent => (OverlayContent as Container)?.Child; + public new Drawable FadingContent => (OverlayContent as Container)?.Child; } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs index 7e728a84ca7c..e45c4607f940 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens.Play; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Tests.Gameplay; namespace osu.Game.Tests.Visual.Gameplay diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index d8817e563cef..57d1f2692626 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -146,7 +146,7 @@ public void TestFrameStarvationAndResume() AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame); checkPaused(true); - AddAssert("time advanced", () => currentFrameStableTime, () => Is.GreaterThan(pausedTime)); + AddAssert("time advanced", () => currentFrameStableTime, () => Is.GreaterThan(pausedTime!)); } [Test] diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneUserTopScoreContainer.cs similarity index 98% rename from osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs rename to osu.Game.Tests/Visual/Gameplay/TestSceneUserTopScoreContainer.cs index 30f1803795b9..8edf83631a87 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneUserTopScoreContainer.cs @@ -6,16 +6,16 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Online.API.Requests.Responses; -using osuTK.Graphics; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; -using osu.Game.Scoring; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Scoring; using osu.Game.Users; +using osuTK.Graphics; -namespace osu.Game.Tests.Visual.SongSelect +namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneUserTopScoreContainer : OsuTestScene { diff --git a/osu.Game.Tests/Visual/Matchmaking/MatchmakingTestScene.cs b/osu.Game.Tests/Visual/Matchmaking/MatchmakingTestScene.cs new file mode 100644 index 000000000000..ebfe79502889 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/MatchmakingTestScene.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Screens; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public abstract partial class MatchmakingTestScene : MultiplayerTestScene + { + protected override Container Content { get; } + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + protected MatchmakingTestScene() + { + BackgroundScreenStack backgroundStack; + + base.Content.AddRange(new Drawable[] + { + backgroundStack = new BackgroundScreenStack(), + Content = new Container { RelativeSizeAxes = Axes.Both } + }); + + backgroundStack.Push(new MatchmakingBackgroundScreen(colourProvider)); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectGrid.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectGrid.cs index 4271742b1ba3..79ee4886a9d5 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectGrid.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectGrid.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 NUnit.Framework; using osu.Framework.Allocation; @@ -13,15 +14,15 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect; -using osu.Game.Tests.Visual.OnlinePlay; using osuTK; namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestSceneBeatmapSelectGrid : OnlinePlayTestScene + public partial class TestSceneBeatmapSelectGrid : MatchmakingTestScene { - private MultiplayerPlaylistItem[] items = null!; + private MatchmakingPlaylistItem[] items = null!; private BeatmapSelectGrid grid = null!; @@ -36,24 +37,44 @@ private void load() .Take(50) .ToArray(); + IEnumerable playlistItems; + if (beatmaps.Length > 0) { - items = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem + playlistItems = Enumerable.Range(1, 50).Select(i => { - ID = i, - BeatmapID = beatmaps[i % beatmaps.Length].OnlineID, - StarRating = i / 10.0, - }).ToArray(); + var beatmap = beatmaps[i % beatmaps.Length]; + + return new MatchmakingPlaylistItem( + new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = beatmap.OnlineID, + StarRating = i / 10.0, + }, + CreateAPIBeatmap(beatmap), + Array.Empty() + ); + }); } else { - items = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem - { - ID = i, - BeatmapID = i, - StarRating = i / 10.0, - }).ToArray(); + playlistItems = Enumerable.Range(1, 50).Select(i => new MatchmakingPlaylistItem( + new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + }, + CreateAPIBeatmap(), + Array.Empty() + )); } + + foreach (var item in playlistItems) + item.Beatmap.StarRating = item.PlaylistItem.StarRating; + + items = playlistItems.ToArray(); } public override void SetUpSteps() @@ -70,8 +91,7 @@ public override void SetUpSteps() AddStep("add items", () => { - foreach (var item in items) - grid.AddItem(item); + grid.AddItems(items); }); AddWaitStep("wait for panels", 3); @@ -85,17 +105,17 @@ public void TestBasic() // test scene is weird. }); - AddStep("add selection 1", () => grid.ChildrenOfType().First().AddUser(new APIUser + AddStep("add selection 1", () => grid.ChildrenOfType().First().AddUser(new APIUser { Id = DummyAPIAccess.DUMMY_USER_ID, Username = "Maarvin", })); - AddStep("add selection 2", () => grid.ChildrenOfType().Skip(5).First().AddUser(new APIUser + AddStep("add selection 2", () => grid.ChildrenOfType().Skip(5).First().AddUser(new APIUser { Id = 2, Username = "peppy", })); - AddStep("add selection 3", () => grid.ChildrenOfType().Skip(10).First().AddUser(new APIUser + AddStep("add selection 3", () => grid.ChildrenOfType().Skip(10).First().AddUser(new APIUser { Id = 1040328, Username = "smoogipoo", @@ -109,7 +129,7 @@ public void TestCompleteRollAnimation() { var (candidateItems, finalItem) = pickRandomItems(5); - grid.RollAndDisplayFinalBeatmap(candidateItems, finalItem); + grid.RollAndDisplayFinalBeatmap(candidateItems, finalItem, finalItem); }); } @@ -138,7 +158,7 @@ public void TestPresentRolledBeatmap() grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); grid.PlayRollAnimation(finalItem, duration: 0); - Scheduler.AddDelayed(() => grid.PresentRolledBeatmap(finalItem), 500); + Scheduler.AddDelayed(() => grid.PresentRolledBeatmap(finalItem, finalItem), 500); }); } @@ -153,7 +173,25 @@ public void TestPresentUnanimouslyChosenBeatmap() grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); grid.PlayRollAnimation(finalItem, duration: 0); - Scheduler.AddDelayed(() => grid.PresentUnanimouslyChosenBeatmap(finalItem), 500); + Scheduler.AddDelayed(() => grid.PresentUnanimouslyChosenBeatmap(finalItem, finalItem), 500); + }); + } + + [Test] + public void TestPresentRandomItem() + { + AddStep("present random item panel", () => + { + var (candidateItems, finalItem) = pickRandomItems(4); + + grid.TransferCandidatePanelsToRollContainer(candidateItems.Append(-1).ToArray(), duration: 0); + grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); + grid.PlayRollAnimation(-1, duration: 0); + + Scheduler.AddDelayed(() => + { + grid.PresentRolledBeatmap(-1, finalItem); + }, 500); }); } @@ -180,7 +218,7 @@ public void TestPanelArrangement(int count) AddStep("display roll order", () => { - var panels = grid.ChildrenOfType().ToArray(); + var panels = grid.ChildrenOfType().ToArray(); for (int i = 0; i < panels.Length; i++) { @@ -197,6 +235,22 @@ public void TestPanelArrangement(int count) }); } + [Test] + public void TestRollAnimationFinalRandom() + { + AddStep("play animation", () => + { + (long[] candidateItems, _) = pickRandomItems(5); + + candidateItems = candidateItems.Append(-1).ToArray(); + long finalItem = items.First(i => !candidateItems.Contains(i.ID)).ID; + + grid.RollAndDisplayFinalBeatmap(candidateItems, -1, finalItem); + }); + + AddWaitStep("wait for animation", 10); + } + private (long[] candidateItems, long finalItem) pickRandomItems(int count) { long[] candidateItems = items.Select(it => it.ID).ToArray(); diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs index 01f76157f120..905c4d63e58c 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs @@ -1,37 +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; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Graphics.Cursor; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Overlays; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect; -using osu.Game.Tests.Visual.Multiplayer; namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestSceneBeatmapSelectPanel : MultiplayerTestScene + public partial class TestSceneBeatmapSelectPanel : MatchmakingTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var room = CreateDefaultRoom(MatchType.Matchmaking); + room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = 0, + StarRating = i / 10.0, + })).ToArray(); + + JoinRoom(room); + }); + } + [Test] public void TestBeatmapPanel() { - BeatmapSelectPanel? panel = null; + MatchmakingSelectPanel? panel = null; AddStep("add panel", () => { + var beatmap = CreateAPIBeatmap(); + + beatmap.TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ]; + + beatmap.BeatmapSet!.HasExplicitContent = true; + beatmap.BeatmapSet!.HasVideo = true; + beatmap.BeatmapSet!.HasStoryboard = true; + beatmap.BeatmapSet.FeaturedInSpotlight = true; + beatmap.BeatmapSet.TrackId = 1; + beatmap.BeatmapSet!.RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ]; + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Child = panel = new BeatmapSelectPanel(new MultiplayerPlaylistItem()) + Child = panel = new MatchmakingSelectPanelBeatmap(new MatchmakingPlaylistItem(new MultiplayerPlaylistItem(), beatmap, [])) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -58,46 +112,60 @@ public void TestBeatmapPanel() AddStep("remove peppy", () => panel!.RemoveUser(new APIUser { Id = 2 })); AddStep("remove maarvin", () => panel!.RemoveUser(new APIUser { Id = 6411631 })); - AddToggleStep("allow selection", value => - { - if (panel != null) - panel.AllowSelection = value; - }); + AddToggleStep("allow selection", value => panel!.AllowSelection = value); } [Test] - public void TestFailedBeatmapLookup() + public void TestRandomPanel() { - AddStep("setup request handle", () => + MatchmakingSelectPanelRandom? panel = null; + + AddStep("add panel", () => { - var api = (DummyAPIAccess)API; - var handler = api.HandleRequest; - api.HandleRequest = req => + Child = new OsuContextMenuContainer { - switch (req) + RelativeSizeAxes = Axes.Both, + Child = panel = new MatchmakingSelectPanelRandom(new MultiplayerPlaylistItem { ID = -1 }) { - case GetBeatmapRequest: - case GetBeatmapsRequest: - req.TriggerFailure(new InvalidOperationException()); - return false; - - default: - return handler?.Invoke(req) ?? false; + Anchor = Anchor.Centre, + Origin = Anchor.Centre, } }; }); + AddStep("add peppy", () => panel!.AddUser(new APIUser + { + Id = 2, + Username = "peppy", + })); + + AddToggleStep("allow selection", value => panel!.AllowSelection = value); + + AddStep("reveal beatmap", () => panel!.PresentAsChosenBeatmap(new MatchmakingPlaylistItem(new MultiplayerPlaylistItem(), CreateAPIBeatmap(), []))); + } + + [Test] + public void TestBeatmapWithMods() + { AddStep("add panel", () => { + MatchmakingSelectPanel? panel; + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Child = new BeatmapSelectPanel(new MultiplayerPlaylistItem()) + Child = panel = new MatchmakingSelectPanelBeatmap(new MatchmakingPlaylistItem(new MultiplayerPlaylistItem(), CreateAPIBeatmap(), [new OsuModHardRock(), new OsuModDoubleTime()])) { Anchor = Anchor.Centre, Origin = Anchor.Centre, } }; + + panel.AddUser(new APIUser + { + Id = 2, + Username = "peppy", + }); }); } } diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingChatDisplay.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingChatDisplay.cs index d8e42cd94699..b22c4dd74ac5 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingChatDisplay.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingChatDisplay.cs @@ -11,7 +11,7 @@ namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestSceneMatchmakingChatDisplay : ScreenTestScene + public partial class TestSceneMatchmakingChatDisplay : MatchmakingTestScene { private MatchmakingChatDisplay? chat; diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingPoolSelector.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingPoolSelector.cs index c05614e9a49f..bd3b75f1b812 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingPoolSelector.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingPoolSelector.cs @@ -22,11 +22,11 @@ public override void SetUpSteps() { Value = [ - new MatchmakingPool { Id = 0, RulesetId = 0, Name = "osu!" }, - new MatchmakingPool { Id = 1, RulesetId = 1, Name = "osu!taiko" }, - new MatchmakingPool { Id = 2, RulesetId = 2, Name = "osu!catch" }, - new MatchmakingPool { Id = 3, RulesetId = 3, Variant = 4, Name = "osu!mania (4k)" }, - new MatchmakingPool { Id = 4, RulesetId = 3, Variant = 7, Name = "osu!mania (7k)" }, + new MatchmakingPool { Id = 0, RulesetId = 0, Name = "Free-for-all" }, + new MatchmakingPool { Id = 1, RulesetId = 1, Name = "1v1" }, + new MatchmakingPool { Id = 2, RulesetId = 2, Name = "1v1" }, + new MatchmakingPool { Id = 3, RulesetId = 3, Variant = 4, Name = "1v1" }, + new MatchmakingPool { Id = 4, RulesetId = 3, Variant = 7, Name = "1v1" }, ] } }); diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs index 07d0fe6ed9f3..4787b195b170 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs @@ -7,6 +7,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking; using osu.Game.Screens.OnlinePlay.Matchmaking.Intro; using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; using osu.Game.Tests.Visual.Multiplayer; @@ -26,7 +27,7 @@ public override void SetUpSteps() { base.SetUpSteps(); - AddStep("load screen", () => LoadScreen(new ScreenIntro())); + AddStep("load screen", () => LoadScreen(new ScreenIntro(MatchmakingPoolType.QuickPlay))); } [Test] diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs index e88b10d30de7..5b60f1e7a1bc 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs @@ -110,6 +110,7 @@ public void TestGameplayFlow() state.CandidateItems = beatmaps.Select(b => b.ID).ToArray(); state.CandidateItem = beatmaps[0].ID; + state.GameplayItem = beatmaps[0].ID; }, waitTime: 35); changeStage(MatchmakingStage.WaitingForClientsBeatmapDownload); diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePanelRoomAward.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePanelRoomAward.cs index bdae6568555a..f03af8b8f5cb 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestScenePanelRoomAward.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePanelRoomAward.cs @@ -3,11 +3,10 @@ using osu.Framework.Graphics; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results; -using osu.Game.Tests.Visual.Multiplayer; namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestScenePanelRoomAward : MultiplayerTestScene + public partial class TestScenePanelRoomAward : MatchmakingTestScene { public override void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs index e894616f9e3d..3b59df7e6040 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs @@ -6,16 +6,16 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect; -using osu.Game.Tests.Visual.Multiplayer; namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestScenePickScreen : MultiplayerTestScene + public partial class TestScenePickScreen : MatchmakingTestScene { private readonly IReadOnlyList users = new[] { @@ -104,8 +104,28 @@ public void TestScreen() long[] candidateItems = selectedItems.ToArray(); long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)]; - screen.RollFinalBeatmap(candidateItems, finalItem); + screen.RollFinalBeatmap(candidateItems, finalItem, finalItem); }); } + + [Test] + public void TestExpiredBeatmapNotShown() + { + SubScreenBeatmapSelect screen = null!; + + AddStep("add screen with expired items", () => + { + MultiplayerClient.ClientRoom!.Playlist = + [ + new MultiplayerPlaylistItem(items[0]) { Expired = true }, + new MultiplayerPlaylistItem(items[1]) + ]; + + Child = new ScreenStack(screen = new SubScreenBeatmapSelect()); + }); + + AddUntilStep("items displayed", () => screen.ChildrenOfType().Any()); + AddAssert("expired item not shown", () => screen.ChildrenOfType().Count(), () => Is.EqualTo(1)); + } } } diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs index 0c78038179ac..8d98eba317a2 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs @@ -11,12 +11,11 @@ using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Matchmaking.Match; -using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Users; namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestScenePlayerPanel : MultiplayerTestScene + public partial class TestScenePlayerPanel : MatchmakingTestScene { private PlayerPanel panel = null!; @@ -123,5 +122,51 @@ public void TestDownloadProgress() AddStep("set download progress 90%", () => MultiplayerClient.ChangeUserBeatmapAvailability(2, BeatmapAvailability.Downloading(0.9f))); AddStep("set locally available", () => MultiplayerClient.ChangeUserBeatmapAvailability(2, BeatmapAvailability.LocallyAvailable())); } + + [Test] + public void TestLongUsername() + { + AddStep("set long username", () => + { + MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Users = + { + UserDictionary = + { + { + 2, new MatchmakingUser + { + UserId = 2, + Placement = 1 + } + } + } + } + }).WaitSafely(); + + Child = panel = new PlayerPanel(new MultiplayerRoomUser(2) + { + User = new APIUser + { + Username = @"ThisIsALongUsername", + Id = 2, + Colour = "99EB47", + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/2/baba245ef60834b769694178f8f6d4f6166c5188c740de084656ad2b80f1eea7.jpeg", + Statistics = new UserStatistics { GlobalRank = null, CountryRank = null } + } + }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + }); + + foreach (var layout in Enum.GetValues()) + { + AddStep($"set layout to {layout}", () => panel.DisplayMode = layout); + } + } } } diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs index 16f15014fbb9..f41416925115 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs @@ -8,17 +8,18 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking.Events; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Matchmaking.Match; -using osu.Game.Tests.Visual.Multiplayer; using osuTK; namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestScenePlayerPanelOverlay : MultiplayerTestScene + public partial class TestScenePlayerPanelOverlay : MatchmakingTestScene { private PlayerPanelOverlay list = null!; @@ -158,5 +159,64 @@ public void ChangeRankings() MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); }); } + + [Test] + public void InteractionSpam() + { + AddStep("join users", () => + { + for (int i = 0; i < 7; i++) + { + MultiplayerClient.AddUser(new MultiplayerRoomUser(i) + { + User = new APIUser + { + Username = $"User {i}" + } + }); + } + }); + AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid); + AddStep("player jump", () => { MultiplayerClient.SendUserMatchRequest(1001, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely(); }); + AddStep("local jumping", () => jumpSpam(false)); + AddWaitStep("wait", 25); + AddStep("group jumping spam", () => jumpSpam(true)); + AddWaitStep("wait", 25); + + AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split); + AddStep("local jumping", () => jumpSpam(false)); + AddWaitStep("wait", 25); + AddStep("group jumping spam", () => jumpSpam(true)); + AddWaitStep("wait", 25); + + AddStep("change to hidden mode", () => list.DisplayStyle = PanelDisplayStyle.Hidden); + AddStep("local jumping", () => jumpSpam(false)); + AddWaitStep("wait", 25); + AddStep("group jumping spam", () => jumpSpam(true)); + AddWaitStep("wait", 25); + } + + private void jumpSpam(bool everyone) + { + for (int i = 0; i < 30; i++) + { + Scheduler.AddDelayed(() => + { + MultiplayerClient.SendUserMatchRequest(1001, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely(); + }, i * 150 + RNG.NextDouble(0, 140)); + + if (!everyone) + continue; + + for (int ii = 0; ii < 7; ii++) + { + int iii = ii; + Scheduler.AddDelayed(() => + { + MultiplayerClient.SendUserMatchRequest(iii, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely(); + }, i * 150 + RNG.NextDouble(0, 140)); + } + } + } } } diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs index 843c20b1e58b..f717849a2a37 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs @@ -11,12 +11,11 @@ using osu.Game.Online.Rooms; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results; -using osu.Game.Tests.Visual.Multiplayer; using osuTK; namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestSceneResultsScreen : MultiplayerTestScene + public partial class TestSceneResultsScreen : MatchmakingTestScene { public override void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs index cbdbd3315843..d1800aca3f7e 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Utils; @@ -14,12 +15,11 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundResults; -using osu.Game.Tests.Visual.Multiplayer; using osuTK; namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestSceneRoundResultsScreen : MultiplayerTestScene + public partial class TestSceneRoundResultsScreen : MatchmakingTestScene { public override void SetUpSteps() { @@ -27,8 +27,15 @@ public override void SetUpSteps() AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking))); WaitForJoined(); + } - setupRequestHandler(); + [TestCase(2)] + [TestCase(4)] + [TestCase(8)] + [TestCase(16)] + public void TestDisplayScores(int scoreCount) + { + setupRequestHandler(scoreCount); AddStep("load screen", () => { @@ -41,7 +48,7 @@ public override void SetUpSteps() }); } - private void setupRequestHandler() + private void setupRequestHandler(int scoreCount) { AddStep("setup request handler", () => { @@ -72,7 +79,7 @@ private void setupRequestHandler() case IndexPlaylistScoresRequest index: var result = new IndexedMultiplayerScores(); - for (int i = 0; i < 8; ++i) + for (int i = 0; i < scoreCount; ++i) { result.Scores.Add(new MultiplayerScore { diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs index dc4f09c55514..a4aa4e2cebc7 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs @@ -9,11 +9,10 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Matchmaking.Match; -using osu.Game.Tests.Visual.Multiplayer; namespace osu.Game.Tests.Visual.Matchmaking { - public partial class TestSceneStageDisplay : MultiplayerTestScene + public partial class TestSceneStageDisplay : MatchmakingTestScene { [Cached] protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs index 955737578aac..801a748105d6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -21,7 +21,7 @@ using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Tests.Visual.Multiplayer { diff --git a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs index 184bb33c2ff4..b10be77fb654 100644 --- a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -15,6 +17,7 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Lounge; @@ -22,6 +25,7 @@ using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Screens.Play; using osu.Game.Tests.Resources; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { @@ -35,7 +39,8 @@ public abstract partial class QueueModeTestScene : ScreenTestScene protected IScreen CurrentScreen => multiplayerComponents.CurrentScreen; protected IScreen CurrentSubScreen => multiplayerComponents.MultiplayerScreen.CurrentSubScreen; - private BeatmapManager beatmaps = null!; + protected BeatmapManager Beatmaps { get; private set; } = null!; + private BeatmapSetInfo importedSet = null!; private RulesetStore rulesets = null!; @@ -49,7 +54,7 @@ private void load(GameHost host, AudioManager audio) BeatmapStore beatmapStore; Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(Beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); @@ -62,13 +67,13 @@ public override void SetUpSteps() AddStep("import beatmap", () => { - beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + Beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); Realm.Write(r => { foreach (var beatmapInfo in r.All()) beatmapInfo.OnlineMD5Hash = beatmapInfo.MD5Hash; }); - importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + importedSet = Beatmaps.GetAllUsableBeatmapSets().First(); InitialBeatmap = importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0); OtherBeatmap = importedSet.Beatmaps.Last(b => b.Ruleset.OnlineID == 0); }); @@ -118,6 +123,30 @@ protected void RunGameplay() AddStep("exit player", () => multiplayerComponents.MultiplayerScreen.MakeCurrent()); } + protected void AddBeatmapFromSongSelect(Func beatmap, RulesetInfo? ruleset = null, IReadOnlyList? mods = null) + { + Screens.Select.SongSelect? songSelect = null; + + AddStep("click add button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for song select", () => (songSelect = CurrentSubScreen as Screens.Select.SongSelect) != null); + AddUntilStep("wait for loaded", () => songSelect.IsCurrentScreen() && !songSelect.AsNonNull().IsFiltering); + + if (ruleset != null) + AddStep($"set {ruleset.Name} ruleset", () => songSelect.AsNonNull().Ruleset.Value = ruleset); + + if (mods != null) + AddStep($"set mods to {string.Join(",", mods.Select(m => m.Acronym))}", () => songSelect.AsNonNull().Mods.Value = mods); + + AddStep("select other beatmap", () => songSelect.AsNonNull().Beatmap.Value = Beatmaps.GetWorkingBeatmap(beatmap())); + AddStep("confirm selection", () => InputManager.Key(Key.Enter)); + AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs index 869b8bb32874..be089224153f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs @@ -1,24 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Screens; -using osu.Framework.Testing; -using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; -using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Screens.Play; -using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { @@ -45,10 +37,10 @@ public void TestSingleItemExpiredAfterGameplay() [Test] public void TestItemAddedToTheEndOfQueue() { - addItem(() => OtherBeatmap); + AddBeatmapFromSongSelect(() => OtherBeatmap); AddUntilStep("playlist has 2 items", () => MultiplayerClient.ClientAPIRoom?.Playlist.Count == 2); - addItem(() => InitialBeatmap); + AddBeatmapFromSongSelect(() => InitialBeatmap); AddUntilStep("playlist has 3 items", () => MultiplayerClient.ClientAPIRoom?.Playlist.Count == 3); AddUntilStep("first item still selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[0].ID); @@ -57,8 +49,8 @@ public void TestItemAddedToTheEndOfQueue() [Test] public void TestNextItemSelectedAfterGameplayFinish() { - addItem(() => OtherBeatmap); - addItem(() => InitialBeatmap); + AddBeatmapFromSongSelect(() => OtherBeatmap); + AddBeatmapFromSongSelect(() => InitialBeatmap); RunGameplay(); @@ -74,8 +66,8 @@ public void TestNextItemSelectedAfterGameplayFinish() [Test] public void TestItemsNotClearedWhenSwitchToHostOnlyMode() { - addItem(() => OtherBeatmap); - addItem(() => InitialBeatmap); + AddBeatmapFromSongSelect(() => OtherBeatmap); + AddBeatmapFromSongSelect(() => InitialBeatmap); // Move to the "other" beatmap. RunGameplay(); @@ -89,14 +81,14 @@ public void TestItemsNotClearedWhenSwitchToHostOnlyMode() [Test] public void TestCorrectItemSelectedAfterNewItemAdded() { - addItem(() => OtherBeatmap); + AddBeatmapFromSongSelect(() => OtherBeatmap); AddUntilStep("selected beatmap is initial beatmap", () => Beatmap.Value.BeatmapInfo.OnlineID == InitialBeatmap.OnlineID); } [Test] public void TestCorrectRulesetSelectedAfterNewItemAdded() { - addItem(() => OtherBeatmap, new CatchRuleset().RulesetInfo); + AddBeatmapFromSongSelect(() => OtherBeatmap, new CatchRuleset().RulesetInfo); AddUntilStep("selected beatmap is initial beatmap", () => Beatmap.Value.BeatmapInfo.OnlineID == InitialBeatmap.OnlineID); AddUntilStep("wait for idle", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Idle); @@ -113,7 +105,7 @@ public void TestCorrectRulesetSelectedAfterNewItemAdded() [Test] public void TestCorrectModsSelectedAfterNewItemAdded() { - addItem(() => OtherBeatmap, mods: new Mod[] { new OsuModDoubleTime() }); + AddBeatmapFromSongSelect(() => OtherBeatmap, mods: new Mod[] { new OsuModDoubleTime() }); AddUntilStep("selected beatmap is initial beatmap", () => Beatmap.Value.BeatmapInfo.OnlineID == InitialBeatmap.OnlineID); AddUntilStep("wait for idle", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Idle); @@ -126,28 +118,5 @@ public void TestCorrectModsSelectedAfterNewItemAdded() AddAssert("mods are correct", () => !((Player)CurrentScreen).Mods.Value.Any()); AddStep("exit player", () => CurrentScreen.Exit()); } - - private void addItem(Func beatmap, RulesetInfo? ruleset = null, IReadOnlyList? mods = null) - { - Screens.Select.SongSelect? songSelect = null; - - AddStep("click add button", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); - InputManager.Click(MouseButton.Left); - }); - - AddUntilStep("wait for song select", () => (songSelect = CurrentSubScreen as Screens.Select.SongSelect) != null); - AddUntilStep("wait for loaded", () => songSelect.AsNonNull().BeatmapSetsLoaded); - - if (ruleset != null) - AddStep($"set {ruleset.Name} ruleset", () => songSelect.AsNonNull().Ruleset.Value = ruleset); - - if (mods != null) - AddStep($"set mods to {string.Join(",", mods.Select(m => m.Acronym))}", () => songSelect.AsNonNull().Mods.Value = mods); - - AddStep("select other beatmap", () => songSelect.AsNonNull().FinaliseSelection(beatmap())); - AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen); - } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index fd589e928ad1..65b3af50f336 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -7,14 +7,13 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Testing; +using osu.Game.Overlays; using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens; using osu.Game.Screens.Footer; using osu.Game.Screens.OnlinePlay; using osu.Game.Utils; @@ -22,12 +21,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { - public partial class TestSceneFreeModSelectOverlay : MultiplayerTestScene + public partial class TestSceneFreeModSelectOverlay : ScreenTestScene { - private FreeModSelectOverlay freeModSelectOverlay = null!; - private FooterButtonFreeMods footerButtonFreeMods = null!; - private ScreenFooter footer = null!; + private TestFreeModSelectOverlayScreen screen = null!; private readonly Bindable>> availableMods = new Bindable>>(); + private readonly Bindable> freeMods = new Bindable>([]); + + private FreeModSelectOverlay freeModSelectOverlay => screen.Overlay; [BackgroundDependencyLoader] private void load(OsuGameBase osuGameBase) @@ -35,6 +35,14 @@ private void load(OsuGameBase osuGameBase) availableMods.BindTo(osuGameBase.AvailableMods); } + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reset selected mods", () => freeMods.Value = []); + } + [Test] public void TestFreeModSelect() { @@ -44,11 +52,6 @@ public void TestFreeModSelect() () => this.ChildrenOfType() .Where(panel => panel.IsPresent) .All(panel => panel.Mod.HasImplementation && panel.Mod.UserPlayable)); - - AddToggleStep("toggle visibility", visible => - { - freeModSelectOverlay.State.Value = visible ? Visibility.Visible : Visibility.Hidden; - }); } [Test] @@ -72,18 +75,16 @@ public void TestSelectAllButtonUpdatesStateWhenSearchTermChanged() AddAssert("select all button enabled", () => this.ChildrenOfType().Single().Enabled.Value); - AddStep("click select all button", navigateAndClick); + AddStep("click select all button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); AddAssert("select all button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); AddStep("change search term", () => freeModSelectOverlay.SearchTerm = "e"); AddAssert("select all button enabled", () => this.ChildrenOfType().Single().Enabled.Value); - - void navigateAndClick() where T : Drawable - { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); - InputManager.Click(MouseButton.Left); - } } [Test] @@ -124,55 +125,14 @@ public void TestSelectDeselectAll() AddAssert("select all button enabled", () => this.ChildrenOfType().Single().Enabled.Value); } - [Test] - public void TestSelectAllViaFooterButtonThenDeselectFromOverlay() - { - createFreeModSelect(); - - AddAssert("overlay select all button enabled", () => this.ChildrenOfType().Single().Enabled.Value); - AddAssert("footer button displays off", () => footerButtonFreeMods.ChildrenOfType().Any(t => t.Text == "off")); - - AddStep("click footer select all button", () => - { - InputManager.MoveMouseTo(footerButtonFreeMods); - InputManager.Click(MouseButton.Left); - }); - - AddUntilStep("all mods selected", assertAllAvailableModsSelected); - AddAssert("footer button displays all", () => footerButtonFreeMods.ChildrenOfType().Any(t => t.Text == "all")); - - AddStep("click deselect all button", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); - InputManager.Click(MouseButton.Left); - }); - AddUntilStep("all mods deselected", () => !freeModSelectOverlay.SelectedMods.Value.Any()); - AddAssert("footer button displays off", () => footerButtonFreeMods.ChildrenOfType().Any(t => t.Text == "off")); - } - private void createFreeModSelect() { - AddStep("create free mod select screen", () => Child = new DependencyProvidingContainer + AddStep("create free mod select screen", () => LoadScreen(screen = new TestFreeModSelectOverlayScreen { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - freeModSelectOverlay = new FreeModSelectOverlay - { - State = { Value = Visibility.Visible } - }, - footerButtonFreeMods = new FooterButtonFreeMods(freeModSelectOverlay) - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Y = -ScreenFooter.HEIGHT, - FreeMods = { BindTarget = freeModSelectOverlay.SelectedMods }, - }, - footer = new ScreenFooter(), - }, - CachedDependencies = new (Type, object)[] { (typeof(ScreenFooter), footer) }, - }); - + FreeMods = { BindTarget = freeMods }, + })); + AddUntilStep("wait until screen is loaded", () => screen.IsLoaded, () => Is.True); + AddStep("show overlay", () => freeModSelectOverlay.Show()); AddUntilStep("all column content loaded", () => freeModSelectOverlay.ChildrenOfType().Any() && freeModSelectOverlay.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); @@ -197,5 +157,50 @@ private bool assertAllAvailableModsSelected() return true; } + + private partial class TestFreeModSelectOverlayScreen : OsuScreen + { + public override bool ShowFooter => true; + + public FreeModSelectOverlay Overlay = null!; + private IDisposable? overlayRegistration; + + public readonly Bindable> FreeMods = new Bindable>([]); + + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + [BackgroundDependencyLoader] + private void load() + { + LoadComponent(Overlay = new FreeModSelectOverlay + { + SelectedMods = { BindTarget = FreeMods } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + overlayRegistration = overlayManager?.RegisterBlockingOverlay(Overlay); + } + + public override IReadOnlyList CreateFooterButtons() => + [ + new FooterButtonFreeMods(Overlay) + { + FreeMods = { BindTarget = FreeMods }, + }, + ]; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + overlayRegistration?.Dispose(); + } + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs index 7d3d30b9f98b..a1ced5698e15 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs @@ -5,6 +5,8 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; @@ -36,6 +38,14 @@ public void TestNewItemCreatedAfterGameplayFinished() AddUntilStep("second playlist item selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[1].ID); } + [Test] + public void TestItemStillSelectedAfterChangeToSameBeatmap() + { + selectNewItem(() => InitialBeatmap); + + AddUntilStep("playlist item still selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[0].ID); + } + [Test] public void TestSettingsUpdatedWhenChangingQueueMode() { @@ -47,14 +57,6 @@ public void TestSettingsUpdatedWhenChangingQueueMode() AddUntilStep("api room updated", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); } - [Test] - public void TestItemStillSelectedAfterChangeToSameBeatmap() - { - selectNewItem(() => InitialBeatmap); - - AddUntilStep("playlist item still selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[0].ID); - } - [Test] public void TestItemStillSelectedAfterChangeToOtherBeatmap() { @@ -80,13 +82,15 @@ public void TestOnlyLastItemChangedAfterGameplayFinished() [Test] public void TestAddItemsAsHost() { - addItem(() => OtherBeatmap); + AddBeatmapFromSongSelect(() => OtherBeatmap); AddUntilStep("playlist contains two items", () => MultiplayerClient.ClientAPIRoom?.Playlist.Count == 2); } private void selectNewItem(Func beatmap) { + Screens.Select.SongSelect? songSelect = null; + AddUntilStep("wait for playlist panels to load", () => { var queueList = this.ChildrenOfType().Single(); @@ -99,26 +103,15 @@ private void selectNewItem(Func beatmap) InputManager.Click(MouseButton.Left); }); - AddUntilStep("wait for song select", () => CurrentSubScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => (songSelect = CurrentSubScreen as Screens.Select.SongSelect) != null); + AddUntilStep("wait for loaded", () => songSelect.IsCurrentScreen() && !songSelect.AsNonNull().IsFiltering); BeatmapInfo otherBeatmap = null!; - AddStep("select other beatmap", () => ((Screens.Select.SongSelect)CurrentSubScreen).FinaliseSelection(otherBeatmap = beatmap())); - + AddStep("select other beatmap", () => songSelect.AsNonNull().Beatmap.Value = Beatmaps.GetWorkingBeatmap(otherBeatmap = beatmap())); + AddStep("confirm selection", () => InputManager.Key(Key.Enter)); AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen); - AddUntilStep("selected item is new beatmap", () => Beatmap.Value.BeatmapInfo.OnlineID == otherBeatmap.OnlineID); - } - private void addItem(Func beatmap) - { - AddStep("click add button", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); - InputManager.Click(MouseButton.Left); - }); - - AddUntilStep("wait for song select", () => CurrentSubScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded); - AddStep("select other beatmap", () => ((Screens.Select.SongSelect)CurrentSubScreen).FinaliseSelection(beatmap())); - AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen); + AddUntilStep("selected item is new beatmap", () => Beatmap.Value.BeatmapInfo.OnlineID == otherBeatmap.OnlineID); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs deleted file mode 100644 index e372d63fde40..000000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using osu.Framework.Graphics; -using osu.Game.Online.API; -using osu.Game.Online.Rooms; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.OnlinePlay.Components; -using osu.Game.Tests.Beatmaps; -using osu.Game.Tests.Visual.OnlinePlay; -using osuTK; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public partial class TestSceneMatchBeatmapDetailArea : OnlinePlayTestScene - { - private Room room = null!; - - public override void SetUpSteps() - { - base.SetUpSteps(); - - AddStep("create area", () => - { - Child = new MatchBeatmapDetailArea(room = new Room()) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500), - CreateNewItem = createNewItem - }; - }); - } - - private void createNewItem() - { - room.Playlist = room.Playlist.Append(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) - { - ID = room.Playlist.Count, - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - RequiredMods = new[] - { - new APIMod(new OsuModHardRock()), - new APIMod(new OsuModDoubleTime()), - new APIMod(new OsuModAutoplay()) - } - }).ToArray(); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index c39708352ed2..68a2fec0ea6d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -10,7 +10,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Tests.Visual.Multiplayer { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 4c487c82886b..b424621dcb76 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -228,7 +228,7 @@ public void TestCreateRoomViaKeyboard() // edit playlist item AddStep("Press select", () => InputManager.Key(Key.Enter)); - AddUntilStep("wait for song select", () => InputManager.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); + waitForSongSelect(); // select beatmap AddStep("Press select", () => InputManager.Key(Key.Enter)); @@ -451,7 +451,7 @@ public void TestPlayStartsWithCorrectBeatmapWhileAtSongSelect() ((MultiplayerMatchSubScreen)currentSubScreen).ShowSongSelect(item); }); - AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); + waitForSongSelect(); AddUntilStep("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == multiplayerClient.ClientRoom?.Playlist.First().BeatmapID); @@ -492,7 +492,7 @@ public void TestPlayStartsWithCorrectRulesetWhileAtSongSelect() ((MultiplayerMatchSubScreen)currentSubScreen).ShowSongSelect(item); }); - AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); + waitForSongSelect(); AddUntilStep("Ruleset matches current item", () => Ruleset.Value.OnlineID == multiplayerClient.ClientRoom?.Playlist.First().RulesetID); @@ -533,7 +533,7 @@ public void TestPlayStartsWithCorrectModsWhileAtSongSelect() ((MultiplayerMatchSubScreen)currentSubScreen).ShowSongSelect(item); }); - AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); + waitForSongSelect(); AddUntilStep("Mods match current item", () => SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.ClientRoom.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); @@ -1051,7 +1051,7 @@ public void TestGameplayStartsWhileInSongSelectWithDifferentRuleset() AddStep("press edit on second item", () => this.ChildrenOfType().Single(i => i.Item.RulesetID == 1) .ChildrenOfType().Single().TriggerClick()); - AddUntilStep("wait for song select", () => InputManager.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); + waitForSongSelect(); AddAssert("ruleset is taiko", () => Ruleset.Value.OnlineID == 1); AddStep("start match", () => multiplayerClient.StartMatch().WaitSafely()); @@ -1249,6 +1249,15 @@ private void createRoom(Func room) AddUntilStep("wait for join", () => multiplayerClient.RoomJoined); } + private void waitForSongSelect() + { + AddUntilStep("wait for song select", () => + { + var songSelect = InputManager.ChildrenOfType().FirstOrDefault(); + return songSelect != null && songSelect.IsCurrentScreen() && !songSelect.IsFiltering; + }); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index 53e265decb78..3cf95b3304a2 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -9,7 +9,7 @@ using osu.Game.Online.Multiplayer; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Tests.Visual.Multiplayer { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs index 6141820cb75a..6e5add006ae4 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs @@ -7,7 +7,7 @@ using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Tests.Visual.Multiplayer { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index e6f3d7e5ac18..b9f2d32cf391 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -16,6 +16,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; @@ -26,8 +27,8 @@ using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Multiplayer; -using osu.Game.Screens.Select; using osu.Game.Tests.Resources; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { @@ -64,7 +65,11 @@ public override void SetUpSteps() { base.SetUpSteps(); - AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("create room", () => + { + Ruleset.Value = new OsuRuleset().RulesetInfo; + room = CreateDefaultRoom(); + }); AddStep("join room", () => JoinRoom(room)); WaitForJoined(); } @@ -80,7 +85,7 @@ private void setUp() LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(room)); }); - AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && !songSelect.IsFiltering); } [Test] @@ -101,19 +106,21 @@ public void TestBeatmapConfirmed() setUp(); AddStep("change ruleset", () => Ruleset.Value = new TaikoRuleset().RulesetInfo); + + AddUntilStep("wait for filtering", () => !songSelect.IsFiltering); AddStep("select beatmap", - () => songSelect.Carousel.SelectBeatmap(selectedBeatmap = beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == new TaikoRuleset().LegacyID))); + () => songSelect.SelectBeatmap(selectedBeatmap = beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == new TaikoRuleset().LegacyID))); AddUntilStep("wait for selection", () => Beatmap.Value.BeatmapInfo.Equals(selectedBeatmap)); AddUntilStep("wait for ongoing operation to complete", () => !OnlinePlayDependencies.OngoingOperationTracker.InProgress.Value); AddStep("set mods", () => SelectedMods.Value = new[] { new TaikoModDoubleTime() }); - AddStep("confirm selection", () => songSelect.FinaliseSelection()); + AddStep("confirm selection", () => InputManager.Key(Key.Enter)); AddUntilStep("song select exited", () => !songSelect.IsCurrentScreen()); - AddAssert("beatmap not changed", () => Beatmap.Value.BeatmapInfo.Equals(selectedBeatmap)); + AddAssert("beatmap not changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo((selectedBeatmap))); AddAssert("ruleset not changed", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); AddAssert("mods not changed", () => SelectedMods.Value.Single() is TaikoModDoubleTime); } @@ -133,10 +140,42 @@ public void TestAllowedModDeselectedWhenRequired(Type allowedMod, Type requiredM // A previous test's mod overlay could still be fading out. AddUntilStep("wait for only one freemod overlay", () => this.ChildrenOfType().Count() == 1); + AddStep("open free mod overlay", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + assertFreeModNotShown(allowedMod); assertFreeModNotShown(requiredMod); } + [Test] + public void TestFreeModsDisplayedOnEnter() + { + AddStep("set room freemods", () => + { + var editedItem = MultiplayerClient.ClientRoom!.CurrentPlaylistItem.Clone(); + + editedItem.AllowedMods = + [ + new APIMod(new OsuModHardRock()), + ]; + + MultiplayerClient.EditPlaylistItem(editedItem); + }); + + setUp(); + + AddStep("open free mod overlay", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + assertFreeModShown(typeof(OsuModHardRock)); + } + [Test] public void TestChangeRulesetImmediatelyAfterLoadComplete() { @@ -154,16 +193,27 @@ public void TestChangeRulesetImmediatelyAfterLoadComplete() songSelect.OnLoadComplete += _ => Ruleset.Value = new TaikoRuleset().RulesetInfo; LoadScreen(songSelect); }); - AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); - AddStep("confirm selection", () => songSelect.FinaliseSelection()); + AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && !songSelect.IsFiltering); + + AddStep("confirm selection", () => InputManager.Key(Key.Enter)); AddAssert("beatmap is taiko", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(1)); AddAssert("ruleset is taiko", () => Ruleset.Value.OnlineID, () => Is.EqualTo(1)); } + private void assertFreeModShown(Type type) + { + AddUntilStep($"{type.ReadableName()} displayed in freemod overlay", + () => this.ChildrenOfType() + .Single() + .ChildrenOfType() + .Where(panel => panel.Visible) + .Any(b => b.Mod.GetType() == type)); + } + private void assertFreeModNotShown(Type type) { - AddAssert($"{type.ReadableName()} not displayed in freemod overlay", + AddUntilStep($"{type.ReadableName()} not displayed in freemod overlay", () => this.ChildrenOfType() .Single() .ChildrenOfType() @@ -185,12 +235,12 @@ private partial class TestMultiplayerMatchSongSelect : MultiplayerMatchSongSelec public new Bindable> FreeMods => base.FreeMods; - public new BeatmapCarousel Carousel => base.Carousel; - public TestMultiplayerMatchSongSelect(Room room, PlaylistItem? itemToEdit = null) : base(room, itemToEdit) { } + + public void SelectBeatmap(BeatmapInfo beatmap) => SelectAndRun(beatmap, () => { }); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs index 9123f63f561f..05430cae3357 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs @@ -12,7 +12,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Tests.Gameplay; namespace osu.Game.Tests.Visual.Multiplayer diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSkipOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSkipOverlay.cs new file mode 100644 index 000000000000..c7ce67d16823 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSkipOverlay.cs @@ -0,0 +1,135 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public partial class TestSceneMultiplayerSkipOverlay : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("add skip overlay", () => + { + GameplayClockContainer gameplayClockContainer; + + var working = CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + + Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new MultiplayerSkipOverlay(120000) + { + RequestSkip = () => MultiplayerClient.VoteToSkipIntro().WaitSafely(), + } + }, + }; + + gameplayClockContainer.Start(); + }); + + AddStep("set playing state", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Playing)); + } + + [Test] + public void TestSkip() + { + for (int i = 0; i < 4; i++) + { + int userId = i; + + AddStep($"join user {userId}", () => + { + MultiplayerClient.AddUser(new APIUser + { + Id = userId, + Username = $"User {userId}" + }); + + MultiplayerClient.ChangeUserState(userId, MultiplayerUserState.Playing); + }); + } + + AddStep("user 0 votes", () => MultiplayerClient.UserVoteToSkipIntro(0).WaitSafely()); + AddStep("local user votes", () => this.ChildrenOfType().Single().TriggerClick()); + AddStep("user 1 votes", () => MultiplayerClient.UserVoteToSkipIntro(1).WaitSafely()); + } + + [Test] + public void TestLeavingBeforeLocalVote() + { + for (int i = 0; i < 4; i++) + { + int userId = i; + + AddStep($"join user {userId}", () => + { + MultiplayerClient.AddUser(new APIUser + { + Id = userId, + Username = $"User {userId}" + }); + + MultiplayerClient.ChangeUserState(userId, MultiplayerUserState.Playing); + }); + } + + AddStep("user 0 votes", () => MultiplayerClient.UserVoteToSkipIntro(0).WaitSafely()); + AddStep("user 1 leaves", () => MultiplayerClient.RemoveUser(new APIUser { Id = 1 })); + AddStep("user 2 leaves", () => MultiplayerClient.RemoveUser(new APIUser { Id = 2 })); + AddStep("user 3 leaves", () => MultiplayerClient.RemoveUser(new APIUser { Id = 3 })); + AddStep("user 0 leaves", () => MultiplayerClient.RemoveUser(new APIUser { Id = 0 })); + } + + [Test] + public void TestLeavingAfterLocalVote() + { + for (int i = 0; i < 4; i++) + { + int userId = i; + + AddStep($"join user {userId}", () => + { + MultiplayerClient.AddUser(new APIUser + { + Id = userId, + Username = $"User {userId}" + }); + + MultiplayerClient.ChangeUserState(userId, MultiplayerUserState.Playing); + }); + } + + AddStep("local user votes", () => this.ChildrenOfType().Single().TriggerClick()); + AddStep("user 0 votes", () => MultiplayerClient.UserVoteToSkipIntro(0).WaitSafely()); + AddStep("user 1 leaves", () => MultiplayerClient.RemoveUser(new APIUser { Id = 1 })); + AddStep("user 2 leaves", () => MultiplayerClient.RemoveUser(new APIUser { Id = 2 })); + AddStep("user 3 leaves", () => MultiplayerClient.RemoveUser(new APIUser { Id = 3 })); + AddStep("user 0 leaves", () => MultiplayerClient.RemoveUser(new APIUser { Id = 0 })); + } + + public partial class TestMultiplayerSkipOverlay : MultiplayerSkipOverlay + { + public TestMultiplayerSkipOverlay() + : base(120000) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index 7135ff930d8e..87c1b67ba7d7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -9,9 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; -using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -22,7 +20,6 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.OnlinePlay; @@ -69,47 +66,45 @@ public override void SetUpSteps() }); AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect(room))); - AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => songSelect.IsLoaded && !songSelect.IsFiltering); } [Test] - public void TestItemAddedIfEmptyOnStart() + public void TestShowScreen() { - AddStep("finalise selection", () => songSelect.FinaliseSelection()); - AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); + AddStep("show screen", () => { }); } [Test] - public void TestItemAddedWhenCreateNewItemClicked() + public void TestItemAddedIfEmptyOnStart() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("finalise selection", () => InputManager.Key(Key.Enter)); AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] - public void TestItemNotAddedIfExistingOnStart() + public void TestItemAddedWhenCreateNewItemClicked() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddStep("finalise selection", () => songSelect.FinaliseSelection()); + AddStep("create new item", () => songSelect.AddNewItem()); AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] public void TestAddSameItemMultipleTimes() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create new item", () => songSelect.AddNewItem()); + AddStep("create new item", () => songSelect.AddNewItem()); AddAssert("playlist has 2 items", () => room.Playlist.Count == 2); } [Test] public void TestAddItemAfterRearrangement() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create new item", () => songSelect.AddNewItem()); + AddStep("create new item", () => songSelect.AddNewItem()); AddStep("rearrange", () => room.Playlist = room.Playlist.Skip(1).Append(room.Playlist[0]).ToArray()); - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create new item", () => songSelect.AddNewItem()); AddAssert("new item has id 2", () => room.Playlist.Last().ID == 2); } @@ -120,9 +115,9 @@ public void TestAddItemAfterRearrangement() public void TestNewItemHasNewModInstances() { AddStep("set dt mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); - AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create item", () => songSelect.AddNewItem()); AddStep("change mod rate", () => ((OsuModDoubleTime)SelectedMods.Value[0]).SpeedChange.Value = 2); - AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create item", () => songSelect.AddNewItem()); AddAssert("item 1 has rate 1.5", () => { @@ -153,7 +148,7 @@ public void TestGlobalModInstancesNotRetained() mod = (OsuModDoubleTime)SelectedMods.Value[0]; }); - AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create item", () => songSelect.AddNewItem()); AddStep("change stored mod rate", () => mod.SpeedChange.Value = 2); AddAssert("item has rate 1.5", () => @@ -166,13 +161,10 @@ public void TestGlobalModInstancesNotRetained() [Test] public void TestFreeModSelectionDisable() { - FooterButtonFreeMods freeMods = null!; - AddAssert("freestyle enabled", () => songSelect.Freestyle.Value, () => Is.True); AddStep("click icon in free mods button", () => { - freeMods = this.ChildrenOfType().Single(); - InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddAssert("mod select not visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); @@ -185,7 +177,7 @@ public void TestFreeModSelectionDisable() AddAssert("freestyle disabled", () => songSelect.Freestyle.Value, () => Is.False); AddStep("click icon in free mods button", () => { - InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddAssert("mod select visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); @@ -201,8 +193,6 @@ protected override void Dispose(bool isDisposing) private partial class TestPlaylistsSongSelect : PlaylistsSongSelect { - public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; - public new IBindable Freestyle => base.Freestyle; public TestPlaylistsSongSelect(Room room) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs index c7499c98b589..09e8253080be 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs @@ -26,8 +26,8 @@ using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Menu; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; using osuTK.Input; diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs index 0ccfb5a4e36a..a908c9cf1bf6 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs @@ -5,7 +5,7 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Screens.Menu; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs index 4f27d9b3230a..6ddd78432910 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs @@ -15,7 +15,7 @@ using osu.Game.Overlays.Settings.Sections.Input; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs index 0a4349d73f4e..d3321753c1ab 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs @@ -5,7 +5,7 @@ using osu.Framework.Extensions; using osu.Game.Configuration; using osu.Game.Screens.Play; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs index 04d7b15295cf..f86727ab10ee 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs @@ -17,7 +17,7 @@ using osu.Game.Screens; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs index 1dd39e5bf9f4..4acc174f72f2 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -16,7 +16,7 @@ using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Menu; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.Navigation { diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index fa337a3ec2b0..06ea5c7da2e8 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -18,8 +18,8 @@ using osu.Game.Screens.Menu; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; -using osu.Game.Screens.SelectV2; -using FilterControl = osu.Game.Screens.SelectV2.FilterControl; +using osu.Game.Screens.Select; +using FilterControl = osu.Game.Screens.Select.FilterControl; namespace osu.Game.Tests.Visual.Navigation { diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenFooterNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenFooterNavigation.cs index 0b17b66dec8f..7c8ff56a57f5 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenFooterNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenFooterNavigation.cs @@ -8,9 +8,13 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osu.Game.Overlays.Mods; using osu.Game.Screens; using osu.Game.Screens.Footer; @@ -23,10 +27,13 @@ public partial class TestSceneScreenFooterNavigation : OsuGameTestScene [Test] public void TestFooterButtonsOnScreenTransitions() { - PushAndConfirm(() => new TestScreenOne()); + PushAndConfirm(() => new TestScreen + { + CreateButtons = () => [new ScreenFooterButton { Text = "Button One" }] + }); AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); - PushAndConfirm(() => new TestScreenTwo()); + PushAndConfirm(() => new TestScreen { CreateButtons = () => [new ScreenFooterButton { Text = "Button Two" }] }); AddUntilStep("button two shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button Two")); AddStep("exit screen", () => Game.ScreenStack.Exit()); @@ -40,7 +47,7 @@ public void TestFooterHidesOldBackButton() AddAssert("footer hidden", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); AddAssert("old back button shown", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Visible)); - PushAndConfirm(() => new TestScreen(true)); + PushAndConfirm(() => new TestScreen()); AddAssert("footer shown", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Visible)); AddAssert("old back button hidden", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Hidden)); @@ -69,10 +76,16 @@ public void TestPushAndExitSubScreens() AddAssert("footer hidden", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); AddAssert("old back button shown", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Visible)); - pushSubScreenAndConfirm(() => screen, () => new TestScreenOne()); + pushSubScreenAndConfirm(() => screen, () => new TestScreen + { + CreateButtons = () => [new ScreenFooterButton { Text = "Button One" }] + }); AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); - pushSubScreenAndConfirm(() => screen, () => new TestScreenTwo()); + pushSubScreenAndConfirm(() => screen, () => new TestScreen + { + CreateButtons = () => [new ScreenFooterButton { Text = "Button Two" }] + }); AddUntilStep("button two shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button Two")); AddStep("exit sub screen", () => screen.ExitSubScreen()); @@ -92,10 +105,16 @@ public void TestPushParentScreenDuringSubScreen() TestScreenWithSubScreen screen = null!; PushAndConfirm(() => screen = new TestScreenWithSubScreen()); - pushSubScreenAndConfirm(() => screen, () => new TestScreenOne()); + pushSubScreenAndConfirm(() => screen, () => new TestScreen + { + CreateButtons = () => [new ScreenFooterButton { Text = "Button One" }] + }); AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); - PushAndConfirm(() => new TestScreenTwo()); + PushAndConfirm(() => new TestScreen + { + CreateButtons = () => [new ScreenFooterButton { Text = "Button Two" }] + }); AddUntilStep("button two shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button Two")); AddStep("exit parent screen", () => Game.ScreenStack.Exit()); @@ -111,14 +130,23 @@ public void TestPushSubScreenWhileNotCurrent() TestScreenWithSubScreen screen = null!; PushAndConfirm(() => screen = new TestScreenWithSubScreen()); - pushSubScreenAndConfirm(() => screen, () => new TestScreenOne()); + pushSubScreenAndConfirm(() => screen, () => new TestScreen + { + CreateButtons = () => [new ScreenFooterButton { Text = "Button One" }] + }); AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); - PushAndConfirm(() => new TestScreenOne()); + PushAndConfirm(() => new TestScreen + { + CreateButtons = () => [new ScreenFooterButton { Text = "Button One" }] + }); AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); // Can't use the helper method because the screen never loads - AddStep("Push new sub screen", () => screen.PushSubScreen(new TestScreenTwo())); + AddStep("Push new sub screen", () => screen.PushSubScreen(new TestScreen + { + CreateButtons = () => [new ScreenFooterButton { Text = "Button Two" }] + })); AddWaitStep("wait for potential screen load", 5); AddUntilStep("button one still shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); @@ -126,6 +154,83 @@ public void TestPushSubScreenWhileNotCurrent() AddUntilStep("button two shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button Two")); } + /// + /// Tests clicking the back button while an overlay is open. + /// + [Test] + public void TestBackButtonWhenOverlayOpen() + { + TestScreen screen = null!; + + PushAndConfirm(() => + { + ShearedOverlayContainer overlay = new TestShearedOverlayContainer(); + + return screen = new TestScreen + { + Overlay = overlay, + CreateButtons = () => + [ + new ScreenFooterButton(overlay) + { + AccentColour = Dependencies.Get().Orange1, + Icon = FontAwesome.Solid.Toolbox, + Text = "One", + }, + new ScreenFooterButton { Text = "Two", Action = () => { } }, + new ScreenFooterButton { Text = "Three", Action = () => { } }, + ], + }; + }); + + AddStep("show overlay", () => screen.Overlay.Show()); + AddAssert("overlay shown", () => screen.Overlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("press back", () => screenFooter.ChildrenOfType().Single().TriggerClick()); + AddAssert("overlay hidden", () => screen.Overlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("screen still shown", () => screen.IsCurrentScreen(), () => Is.True); + } + + /// + /// Tests clicking the back button on an overlay with `BackButtonPressed` being overridden. + /// + [Test] + public void TestBackButtonWithCustomBackButtonPressed() + { + TestScreen screen = null!; + TestShearedOverlayContainer overlay = null!; + + PushAndConfirm(() => + { + return screen = new TestScreen + { + Overlay = overlay = new TestShearedOverlayContainer(), + CreateButtons = () => + [ + new ScreenFooterButton(overlay) + { + AccentColour = Dependencies.Get().Orange1, + Icon = FontAwesome.Solid.Toolbox, + Text = "One", + }, + new ScreenFooterButton { Text = "Two", Action = () => { } }, + new ScreenFooterButton { Text = "Three", Action = () => { } }, + ], + }; + }); + + AddStep("show overlay", () => screen.Overlay.Show()); + AddAssert("overlay shown", () => screen.Overlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddStep("set block count", () => overlay.BackButtonCount = 1); + + AddStep("press back", () => screenFooter.ChildrenOfType().Single().TriggerClick()); + AddAssert("overlay still shown", () => screen.Overlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("press back again", () => screenFooter.ChildrenOfType().Single().TriggerClick()); + AddAssert("overlay hidden", () => screen.Overlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("screen still shown", () => screen.IsCurrentScreen(), () => Is.True); + } + private void pushSubScreenAndConfirm(Func target, Func newScreen) { Screen screen = null!; @@ -142,39 +247,45 @@ private void pushSubScreenAndConfirm(Func target, Func< && (previousScreen == null || previousScreen.GetChildScreen() == screen)); } - private partial class TestScreenOne : OsuScreen + private partial class TestScreen : OsuScreen { - public override bool ShowFooter => true; + public override bool ShowFooter { get; } - [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + public Func> CreateButtons = Array.Empty; - public override IReadOnlyList CreateFooterButtons() => new[] - { - new ScreenFooterButton { Text = "Button One" }, - }; - } + public ShearedOverlayContainer Overlay = new TestShearedOverlayContainer(); - private partial class TestScreenTwo : OsuScreen - { - public override bool ShowFooter => true; + private IDisposable? overlayRegistration; [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - public override IReadOnlyList CreateFooterButtons() => new[] + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + public TestScreen(bool showFooter = true) { - new ScreenFooterButton { Text = "Button Two" }, - }; - } + ShowFooter = showFooter; + } - private partial class TestScreen : OsuScreen - { - public override bool ShowFooter { get; } + [BackgroundDependencyLoader] + private void load() + { + LoadComponent(Overlay); + } - public TestScreen(bool footer) + protected override void LoadComplete() { - ShowFooter = footer; + base.LoadComplete(); + overlayRegistration = overlayManager?.RegisterBlockingOverlay(Overlay); + } + + public override IReadOnlyList CreateFooterButtons() => CreateButtons.Invoke(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + overlayRegistration?.Dispose(); } } @@ -196,5 +307,66 @@ public TestScreenWithSubScreen() public void ExitSubScreen() => SubScreenStack.Exit(); } + + private partial class TestShearedOverlayContainer : ShearedOverlayContainer + { + public TestShearedOverlayContainer() + : base(OverlayColourScheme.Orange) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Header.Title = "Test overlay"; + Header.Description = "An overlay that is made purely for testing purposes."; + } + + public int BackButtonCount; + + public override bool OnBackButton() + { + if (BackButtonCount > 0) + { + BackButtonCount--; + return true; + } + + return false; + } + + public override VisibilityContainer CreateFooterContent() => new TestFooterContent(); + + public partial class TestFooterContent : VisibilityContainer + { + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Children = new[] + { + new ShearedButton { Width = 200, Text = "Action #1", Action = () => { } }, + new ShearedButton { Width = 140, Text = "Action #2", Action = () => { } }, + } + }; + } + + protected override void PopIn() + { + this.MoveToY(0, 400, Easing.OutQuint) + .FadeIn(400, Easing.OutQuint); + } + + protected override void PopOut() + { + this.MoveToY(-20f, 200, Easing.OutQuint) + .FadeOut(200, Easing.OutQuint); + } + } + } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 8a0c9f561ccf..68aaba6c68f0 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -24,6 +24,7 @@ using osu.Game.Extensions; using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Notifications.WebSocket; @@ -33,6 +34,7 @@ using osu.Game.Overlays.Mods; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Toolbar; +using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mania.Configuration; @@ -46,15 +48,16 @@ using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select.Leaderboards; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Resources; using osu.Game.Utils; using osuTK; using osuTK.Input; +using CollectionDropdown = osu.Game.Screens.Select.CollectionDropdown; namespace osu.Game.Tests.Visual.Navigation { @@ -92,21 +95,22 @@ public void TestConfirmationRequiredToDiscardPlaylist(bool withPlaylistItemAdded AddStep("edit playlist", () => InputManager.Key(Key.Enter)); - AddUntilStep("wait for song select", () => (playlistScreen.CurrentSubScreen as PlaylistsSongSelect)?.BeatmapSetsLoaded == true); + AddUntilStep("wait for song select", () => playlistScreen.CurrentSubScreen is PlaylistsSongSelect songSelect && songSelect.IsLoaded && !songSelect.IsFiltering); AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); AddStep("add item", () => InputManager.Key(Key.Enter)); + AddStep("exit screen", () => InputManager.Key(Key.Escape)); AddUntilStep("wait for return to playlist screen", () => playlistScreen.CurrentSubScreen is PlaylistsRoomSubScreen); AddStep("go back to song select", () => { - InputManager.MoveMouseTo(playlistScreen.ChildrenOfType().Single(b => b.Text == "Edit playlist")); + InputManager.MoveMouseTo(playlistScreen.ChildrenOfType().Single(b => b.Text == "+ Add more beatmaps")); InputManager.Click(MouseButton.Left); }); - AddUntilStep("wait for song select", () => (playlistScreen.CurrentSubScreen as PlaylistsSongSelect)?.BeatmapSetsLoaded == true); + AddUntilStep("wait for song select", () => playlistScreen.CurrentSubScreen is PlaylistsSongSelect songSelect && songSelect.IsLoaded && !songSelect.IsFiltering); AddStep("press home button", () => { @@ -139,13 +143,12 @@ public void TestConfirmationRequiredToDiscardPlaylist(bool withPlaylistItemAdded [Test] public void TestExitSongSelectWithEscape() { - SoloSongSelect songSelect = null; ModSelectOverlay modSelect = null; - PushAndConfirm(() => songSelect = new SoloSongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("Show mods overlay", () => { - modSelect = songSelect!.ChildrenOfType().Single(); + modSelect = Game!.ChildrenOfType().Single(); modSelect.Show(); }); AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); @@ -195,14 +198,14 @@ public void TestSongSelectBackActionHandling() AddStep("set filter again", () => filterControlTextBox().Current.Value = "test"); AddStep("open collections dropdown", () => { - InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); + InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddStep("press back once", () => InputManager.Click(MouseButton.Button1)); AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen == songSelect); AddAssert("collections dropdown closed", () => songSelect - .ChildrenOfType().Single() + .ChildrenOfType().Single() .ChildrenOfType.DropdownMenu>().Single().State == MenuState.Closed); AddStep("press back a second time", () => InputManager.Click(MouseButton.Button1)); @@ -307,17 +310,15 @@ public void TestSongSelectScrollHandling() [Test] public void TestOpenModSelectOverlayUsingAction() { - SoloSongSelect songSelect = null; - - PushAndConfirm(() => songSelect = new SoloSongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("Show mods overlay", () => InputManager.Key(Key.F1)); - AddAssert("Overlay was shown", () => songSelect!.ChildrenOfType().Single().State.Value == Visibility.Visible); + AddAssert("Overlay was shown", () => Game!.ChildrenOfType().Single().State.Value == Visibility.Visible); } [Test] public void TestAttemptPlayBeatmapWrongHashFails() { - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); PushAndConfirm(() => songSelect = new SoloSongSelect()); @@ -352,7 +353,7 @@ public void TestAttemptPlayBeatmapWrongHashFails() [Test] public void TestAttemptPlayBeatmapMissingFails() { - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); PushAndConfirm(() => songSelect = new SoloSongSelect()); @@ -386,7 +387,7 @@ public void TestOffsetAdjustDuringPause() { Player player = null; - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; PushAndConfirm(() => songSelect = new SoloSongSelect()); AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); @@ -429,7 +430,7 @@ public void TestScrollSpeedAdjustDuringGameplay() { Player player = null; - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; PushAndConfirm(() => songSelect = new SoloSongSelect()); AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); @@ -483,7 +484,7 @@ public void TestOffsetAdjustDuringGameplay() { Player player = null; - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; PushAndConfirm(() => songSelect = new SoloSongSelect()); AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); @@ -526,7 +527,7 @@ public void TestRetryCountIncrements() { Player player = null; - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; PushAndConfirm(() => songSelect = new SoloSongSelect()); AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); @@ -728,7 +729,7 @@ public void TestExitSongSelectWithClick() PushAndConfirm(() => songSelect = new SoloSongSelect()); AddStep("Show mods overlay", () => { - modSelect = songSelect!.ChildrenOfType().Single(); + modSelect = Game!.ChildrenOfType().Single(); modSelect.Show(); }); AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); @@ -803,13 +804,12 @@ public void TestModSelectInput() { AddUntilStep("Wait for toolbar to load", () => Game.Toolbar.IsLoaded); - SoloSongSelect songSelect = null; ModSelectOverlay modSelect = null; - PushAndConfirm(() => songSelect = new SoloSongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("Show mods overlay", () => { - modSelect = songSelect!.ChildrenOfType().Single(); + modSelect = Game!.ChildrenOfType().Single(); modSelect.Show(); }); AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); @@ -1195,9 +1195,9 @@ public void TestTouchScreenDetectionAtSongSelect() AddStep("close settings sidebar", () => InputManager.Key(Key.Escape)); - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; AddRepeatStep("go to solo", () => InputManager.Key(Key.P), 3); - AddUntilStep("wait for song select", () => (songSelect = Game.ScreenStack.CurrentScreen as Screens.SelectV2.SongSelect) != null); + AddUntilStep("wait for song select", () => (songSelect = Game.ScreenStack.CurrentScreen as Screens.Select.SongSelect) != null); AddUntilStep("wait for beatmap sets loaded", () => songSelect.CarouselItemsPresented); AddStep("switch to osu! ruleset", () => @@ -1282,7 +1282,7 @@ public void TestTouchScreenDetectionInGame() [Test] public void TestExitSongSelectAndImmediatelyClickLogo() { - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; PushAndConfirm(() => songSelect = new SoloSongSelect()); AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); @@ -1313,7 +1313,7 @@ public void TestPresentBeatmapAfterDeletion() { BeatmapSetInfo beatmap = null; - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; PushAndConfirm(() => songSelect = new SoloSongSelect()); AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); @@ -1331,6 +1331,34 @@ public void TestPresentBeatmapAfterDeletion() AddAssert("still nothing selected", () => Game.Beatmap.IsDefault); } + [Test] + public void TestVolumeMeterDragDoesNotDismissFocusedOverlay() + { + AddStep("show beatmap overlay", () => Game.ShowBeatmapSet(1)); + AddUntilStep("beatmap overlay still visible", + () => Game.ChildrenOfType().SingleOrDefault()?.State.Value, + () => Is.EqualTo(Visibility.Visible)); + AddStep("set game volume to max", () => Game.Dependencies.Get().SetValue(FrameworkSetting.VolumeUniversal, 1d)); + AddStep("move to centre", () => InputManager.MoveMouseTo(Game)); + AddStep("alt-scroll down", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.ScrollVerticalBy(-1); + InputManager.ReleaseKey(Key.AltLeft); + }); + AddUntilStep("wait for volume overlay to show", () => Game.ChildrenOfType().SingleOrDefault()?.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddStep("start dragging meter", () => + { + InputManager.MoveMouseTo(Game.ChildrenOfType().First().ChildrenOfType().First()); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("drag away", () => InputManager.MoveMouseTo(Game.ChildrenOfType().First().ChildrenOfType().First(), new Vector2(0, -100))); + AddStep("release mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("beatmap overlay still visible", + () => Game.ChildrenOfType().SingleOrDefault()?.State.Value, + () => Is.EqualTo(Visibility.Visible)); + } + private Func playToResults() { var player = playToCompletion(); @@ -1344,7 +1372,7 @@ private Func playToCompletion() IWorkingBeatmap beatmap() => Game.Beatmap.Value; - Screens.SelectV2.SongSelect songSelect = null; + Screens.Select.SongSelect songSelect = null; PushAndConfirm(() => songSelect = new SoloSongSelect()); AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index 0e1fa634395e..97dfcd75b584 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -27,7 +27,7 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Skinning; using osu.Game.Tests.Beatmaps.IO; using osuTK; @@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.Navigation public partial class TestSceneSkinEditorNavigation : OsuGameTestScene { private SoloSongSelect songSelect; - private ModSelectOverlay modSelect => songSelect.ChildrenOfType().First(); + private ModSelectOverlay modSelect => Game.ChildrenOfType().First(); private SkinEditor skinEditor => Game.ChildrenOfType().FirstOrDefault(); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs index 4295e9e88e80..461061c56846 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs @@ -24,17 +24,13 @@ using osu.Game.Screens.Menu; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Resources; using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation { - /// - /// Tests copied out of `TestSceneScreenNavigation` which are specific to song select. - /// These are for SongSelectV2. Eventually, the tests in the above class should be deleted along with old song select. - /// public partial class TestSceneSongSelectNavigation : OsuGameTestScene { [Test] @@ -86,6 +82,8 @@ public void TestEditBeatmap() AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); AddStep("open menu", () => InputManager.Key(Key.F3)); + AddUntilStep("wait for footer focus", () => InputManager.FocusedDrawable is FooterButtonOptions.Popover); + AddStep("trigger edit", () => { // TODO: should be 5, not 4. @@ -277,7 +275,7 @@ public void TestSelectionNotLostWithConvertedBeatmapsShown() /// /// Note: This test was written to demonstrate the failure described at https://github.com/ppy/osu/issues/35023, /// but because the failure scenario there entailed a race condition, it was possible for the test to pass regardless - /// unless was increased. + /// unless was increased. /// [Test] public void TestPresentFromResults() diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/Online/TestSceneAdvancedStats.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs rename to osu.Game.Tests/Visual/Online/TestSceneAdvancedStats.cs index 3afc8cd1a4a7..2ceb8ff0be55 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneAdvancedStats.cs @@ -12,15 +12,15 @@ using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Localisation; +using osu.Game.Overlays.BeatmapSet; using osu.Game.Rulesets; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.Select.Details; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.SongSelect +namespace osu.Game.Tests.Visual.Online { [System.ComponentModel.Description("Advanced beatmap statistics display")] public partial class TestSceneAdvancedStats : OsuTestScene diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index b164c530cb26..9f7930d09ae3 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -22,7 +22,6 @@ using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.Select.Details; using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Online @@ -72,6 +71,10 @@ public void TestLocalBeatmaps() Preview = @"https://b.ppy.sh/preview/12345.mp3", PlayCount = 123, FavouriteCount = 456, + NominationStatus = new BeatmapSetNominationStatus + { + Current = 2, + }, Submitted = DateTime.Now, Ranked = DateTime.Now, BPM = 111, @@ -274,7 +277,7 @@ public void TestBeatmapSetHasVideoOrStoryboard() public void TestSelectedModsDontAffectStatistics() { AddStep("show map", () => overlay.ShowBeatmapSet(getBeatmapSet())); - AddAssert("AR displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((0, 0))); + AddAssert("AR displayed as 7", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((7.0f, 7.0f))); AddStep("set AR10 diff adjust", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust @@ -282,7 +285,7 @@ public void TestSelectedModsDontAffectStatistics() ApproachRate = { Value = 10 } } }); - AddAssert("AR still displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((0, 0))); + AddAssert("AR still displayed as 7", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((7.0f, 7.0f))); } [Test] diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs index 69c9faa9d365..89be688cf2fa 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs @@ -12,7 +12,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; -using osu.Game.Screens.Select.Details; namespace osu.Game.Tests.Visual.Online { diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs index 59c96ec7195d..7f88f1ebd1c3 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs @@ -16,7 +16,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; -using osu.Game.Screens.Select.Details; using osuTK; using osuTK.Graphics; diff --git a/osu.Game.Tests/Visual/Online/TestSceneClickableTeamFlag.cs b/osu.Game.Tests/Visual/Online/TestSceneClickableTeamFlag.cs new file mode 100644 index 000000000000..525cc9758153 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneClickableTeamFlag.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.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.Cursor; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneClickableTeamFlag : OsuManualInputManagerTestScene + { + [SetUpSteps] + public void SetUp() + { + AddStep("create flags", () => + { + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(10f), + Children = new[] + { + new ClickableTeamFlag( + new APITeam + { + Id = 1, + Name = "Collective Wangs", + ShortName = "WANG", + }, showTooltipOnHover: false) { Width = 300, Height = 150 }, + new ClickableTeamFlag( + new APITeam + { + Id = 2, + Name = "mom?", + ShortName = "MOM", + FlagUrl = "https://assets.ppy.sh/teams/flag/1/b46fb10dbfd8a35dc50e6c00296c0dc6172dffc3ed3d3a4b379277ba498399fe.png", + }, showTooltipOnHover: true) { Width = 300, Height = 150 }, + }, + }; + }); + } + + [Test] + public void TestHover() + { + AddStep("hover flag with no tooltip", () => InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(0))); + AddWaitStep("wait", 3); + AddAssert("tooltip is not visible", () => this.ChildrenOfType().FirstOrDefault()?.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddStep("hover flag with tooltip", () => InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(1))); + AddUntilStep("wait for tooltip to show", () => this.ChildrenOfType().FirstOrDefault()?.State.Value, () => Is.EqualTo(Visibility.Visible)); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs index a1d0d408114d..a151d22a9afc 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs @@ -3,18 +3,16 @@ using System; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; -using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; using osu.Game.Online.Spectator; using osu.Game.Overlays; -using osu.Game.Overlays.Dashboard; -using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Overlays.Dashboard.CurrentlyOnline; using osu.Game.Tests.Visual.Metadata; using osu.Game.Tests.Visual.Spectator; using osu.Game.Users; @@ -23,6 +21,26 @@ namespace osu.Game.Tests.Visual.Online { public partial class TestSceneCurrentlyOnlineDisplay : OsuTestScene { + private static readonly string[] usernames = + { + "fieryrage", + "Kerensa", + "MillhioreF", + "Player01", + "smoogipoo", + "Ephemeral", + "BTMC", + "Cilvery", + "m980", + "HappyStick", + "LittleEndu", + "frenzibyte", + "Zallius", + "BanchoBot", + "rocketminer210", + "pishifat" + }; + private readonly APIUser streamingUser = new APIUser { Id = 2, Username = "Test user" }; private TestSpectatorClient spectatorClient = null!; @@ -36,11 +54,34 @@ public void SetUpSteps() { spectatorClient = new TestSpectatorClient(); metadataClient = new TestMetadataClient(); - var lookupCache = new TestUserLookupCache(); + + ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case LookupUsersRequest lookupUsersRequest: + var users = lookupUsersRequest.UserIds.Select(id => + { + // tests against failed lookups + if (id == 13) + return null; + + return new APIUser + { + Id = id, + Username = usernames[id % usernames.Length], + }; + }).ToList(); + lookupUsersRequest.TriggerSuccess(new GetUsersResponse { Users = users }); + return true; + + default: + return false; + } + }; Children = new Drawable[] { - lookupCache, spectatorClient, metadataClient, new DependencyProvidingContainer @@ -50,13 +91,9 @@ public void SetUpSteps() { (typeof(SpectatorClient), spectatorClient), (typeof(MetadataClient), metadataClient), - (typeof(UserLookupCache), lookupCache), (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Purple)), }, - Child = currentlyOnline = new CurrentlyOnlineDisplay - { - RelativeSizeAxes = Axes.Both, - } + Child = currentlyOnline = new CurrentlyOnlineDisplay() }, }; }); @@ -69,17 +106,18 @@ public void TestBasicDisplay() AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); - AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == 2); - AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.False); + AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == 2); + AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().CanSpectate.Value, () => Is.False); AddStep("User began playing", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); - AddAssert("Spectate button enabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.True); + AddAssert("Spectate button enabled", () => currentlyOnline.ChildrenOfType().First().CanSpectate.Value, () => Is.True); - AddStep("User finished playing", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); - AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.False); + AddStep("User finished playing", + () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); + AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().CanSpectate.Value, () => Is.False); AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); - AddUntilStep("Panel no longer present", () => !currentlyOnline.ChildrenOfType().Any()); + AddUntilStep("Panel no longer present", () => !currentlyOnline.ChildrenOfType().Any()); AddStep("End watching user presence", () => token.Dispose()); } @@ -90,49 +128,14 @@ public void TestUserWasPlayingBeforeWatchingUserPresence() AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); - AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == streamingUser.Id); - AddAssert("Spectate button enabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.True); + AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == streamingUser.Id); + AddAssert("Spectate button enabled", () => currentlyOnline.ChildrenOfType().First().CanSpectate.Value, () => Is.True); - AddStep("User finished playing", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); - AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.False); + AddStep("User finished playing", + () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); + AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().CanSpectate.Value, () => Is.False); AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); AddStep("End watching user presence", () => token.Dispose()); } - - internal partial class TestUserLookupCache : UserLookupCache - { - private static readonly string[] usernames = - { - "fieryrage", - "Kerensa", - "MillhioreF", - "Player01", - "smoogipoo", - "Ephemeral", - "BTMC", - "Cilvery", - "m980", - "HappyStick", - "LittleEndu", - "frenzibyte", - "Zallius", - "BanchoBot", - "rocketminer210", - "pishifat" - }; - - protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) - { - // tests against failed lookups - if (lookup == 13) - return Task.FromResult(null); - - return Task.FromResult(new APIUser - { - Id = lookup, - Username = usernames[lookup % usernames.Length], - }); - } - } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs b/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs index 6a077708e325..fcae9ab7be40 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Framework.Utils; @@ -142,9 +143,169 @@ public void TestBackgroundAlternating() AddRepeatStep("check background", () => { // +1 because the day separator take one index - Assert.AreEqual((checkCount + 1) % 2 == 0, drawableChannel.ChildrenOfType().ToList()[checkCount].AlternatingBackground); + ClassicAssert.AreEqual((checkCount + 1) % 2 == 0, drawableChannel.ChildrenOfType().ToList()[checkCount].AlternatingBackground); checkCount++; }, 10); } + + [Test] + public void TestAlternatingBackgroundDoesNotChangeAtMaxHistory() + { + AddStep("fill up the channel", () => + { + for (int i = 0; i < Channel.MAX_HISTORY; i++) + { + channel.AddNewMessages(new Message + { + ChannelId = channel.Id, + Content = $"Message {i}", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 3, + Username = "LocalUser " + RNG.Next(0, int.MaxValue - 100).ToString("N") + } + }); + } + }); + + AddUntilStep($"{Channel.MAX_HISTORY} messages present", () => drawableChannel.ChildrenOfType().Count(), () => Is.EqualTo(Channel.MAX_HISTORY)); + + ChatLine? lastLine = null; + bool lastLineAlternatingBackground = false; + + AddStep("grab last line", () => + { + lastLine = drawableChannel.ChildrenOfType().Last(); + lastLineAlternatingBackground = lastLine.AlternatingBackground; + }); + + AddStep("add another message", () => channel.AddNewMessages(new Message + { + ChannelId = channel.Id, + Content = "One final message", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 3, + Username = "LocalUser " + RNG.Next(0, int.MaxValue - 100).ToString("N") + } + })); + + AddAssert("second-last message has same background", () => lastLine!.AlternatingBackground, () => Is.EqualTo(lastLineAlternatingBackground)); + } + + [Test] + public void TestAlternatingBackgroundUpdatedOnRemoval() + { + AddStep("add 3 messages", () => + { + for (int i = 0; i < 3; i++) + { + channel.AddNewMessages(new Message + { + ChannelId = channel.Id, + Content = $"Message {i}", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = i, + Username = "LocalUser " + RNG.Next(0, int.MaxValue - 100).ToString("N") + } + }); + } + }); + + AddUntilStep("3 messages present", () => drawableChannel.ChildrenOfType().Count(), () => Is.EqualTo(3)); + assertAlternatingBackground(0, false); + assertAlternatingBackground(1, true); + assertAlternatingBackground(2, false); + + AddStep("remove middle message", () => channel.RemoveMessagesFromUser(1)); + AddUntilStep("2 messages present", () => drawableChannel.ChildrenOfType().Count(), () => Is.EqualTo(2)); + assertAlternatingBackground(0, true); + assertAlternatingBackground(1, false); + + void assertAlternatingBackground(int lineIndex, bool shouldBeAlternating) + => AddAssert($"line {lineIndex} {(shouldBeAlternating ? "has" : "does not have")} alternating background", + () => drawableChannel.ChildrenOfType().ElementAt(lineIndex).AlternatingBackground, + () => Is.EqualTo(shouldBeAlternating)); + } + + [Test] + public void TestTimestampsUpdateOnRemoval() + { + AddStep("add 3 messages", () => + { + channel.AddNewMessages( + new Message + { + ChannelId = channel.Id, + Content = "Message 0", + Timestamp = new DateTimeOffset(2022, 11, 21, 20, 0, 0, TimeSpan.Zero), + Sender = new APIUser + { + Id = 0, + Username = "LocalUser " + RNG.Next(0, int.MaxValue - 100).ToString("N") + } + }, + new Message + { + ChannelId = channel.Id, + Content = "Message 1", + Timestamp = new DateTimeOffset(2022, 11, 21, 20, 0, 0, TimeSpan.Zero).AddSeconds(1), + Sender = new APIUser + { + Id = 1, + Username = "LocalUser " + RNG.Next(0, int.MaxValue - 100).ToString("N") + } + }, + new Message + { + ChannelId = channel.Id, + Content = "Message 2", + Timestamp = new DateTimeOffset(2022, 11, 21, 20, 0, 0, TimeSpan.Zero).AddMinutes(1), + Sender = new APIUser + { + Id = 2, + Username = "LocalUser " + RNG.Next(0, int.MaxValue - 100).ToString("N") + } + }, + new Message + { + ChannelId = channel.Id, + Content = "Message 3", + Timestamp = new DateTimeOffset(2022, 11, 21, 20, 0, 0, TimeSpan.Zero).AddMinutes(1).AddSeconds(1), + Sender = new APIUser + { + Id = 3, + Username = "LocalUser " + RNG.Next(0, int.MaxValue - 100).ToString("N") + } + } + ); + }); + + AddUntilStep("4 messages present", () => drawableChannel.ChildrenOfType().Count(), () => Is.EqualTo(4)); + assertTimestamp(0, true); + assertTimestamp(1, false); + assertTimestamp(2, true); + assertTimestamp(3, false); + + AddStep("remove message 0", () => channel.RemoveMessagesFromUser(0)); + AddUntilStep("3 messages present", () => drawableChannel.ChildrenOfType().Count(), () => Is.EqualTo(3)); + assertTimestamp(0, true); + assertTimestamp(1, true); + assertTimestamp(2, false); + + AddStep("remove message 2", () => channel.RemoveMessagesFromUser(2)); + AddUntilStep("2 messages present", () => drawableChannel.ChildrenOfType().Count(), () => Is.EqualTo(2)); + assertTimestamp(0, true); + assertTimestamp(1, true); + + void assertTimestamp(int lineIndex, bool shouldHaveTimestamp) + => AddAssert($"line {lineIndex} {(shouldHaveTimestamp ? "has" : "does not have")} timestamp", + () => drawableChannel.ChildrenOfType().ElementAt(lineIndex).RequiresTimestamp, + () => Is.EqualTo(shouldHaveTimestamp)); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs index 07fe8c6172cb..beabf6711c0d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs @@ -26,7 +26,7 @@ public TestSceneGlobalRankDisplay() Direction = FillDirection.Full, Padding = new MarginPadding(20), Spacing = new Vector2(40), - ChildrenEnumerable = new int?[] { 64, 423, 1453, 3468, 18_367, 48_342, 178_432, 375_231, 897_783, null }.Select(createDisplay) + ChildrenEnumerable = new int?[] { 64, 423, 1_453, 3_468, 8_367, 48_342, 78_432, 375_231, 897_783, null }.Select(createDisplay) }; private GlobalRankDisplay createDisplay(int? rank) => new GlobalRankDisplay diff --git a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs index 4c67f778a2a4..e4e79e645969 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs @@ -4,9 +4,9 @@ using osu.Game.Overlays.BeatmapSet; using osu.Framework.Graphics; using osu.Framework.Bindables; -using osu.Game.Screens.Select.Leaderboards; using osu.Framework.Allocation; using osu.Game.Overlays; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Tests.Visual.Online { diff --git a/osu.Game.Tests/Visual/Online/TestSceneOnlineUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneOnlineUserPanel.cs new file mode 100644 index 000000000000..4d81443f29c4 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneOnlineUserPanel.cs @@ -0,0 +1,111 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; +using osu.Game.Overlays; +using osu.Game.Overlays.Dashboard.CurrentlyOnline; +using osu.Game.Rulesets; +using osu.Game.Tests.Visual.Metadata; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public partial class TestSceneOnlineUserPanel : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + [Resolved] + private IRulesetStore rulesetStore { get; set; } = null!; + + private TestMetadataClient metadataClient = null!; + private OnlineUserListPanel panel = null!; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(MetadataClient), metadataClient = new TestMetadataClient()) + ], + Children = new Drawable[] + { + metadataClient, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Spacing = new Vector2(10f), + Children = new Drawable[] + { + new OnlineUserGridPanel(new APIUser + { + Username = @"flyte", + Id = 3103765, + CountryCode = CountryCode.JP, + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", + WasRecentlyOnline = true + }), + new OnlineUserGridPanel(new APIUser + { + Username = @"peppy", + Id = 2, + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + IsSupporter = true, + SupportLevel = 3, + }), + new OnlineUserListPanel(new APIUser + { + Username = @"flyte", + Id = 3103765, + CountryCode = CountryCode.JP, + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", + WasRecentlyOnline = true + }), + panel = new OnlineUserListPanel(new APIUser + { + Username = @"peppy", + Id = 2, + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + LastVisit = DateTimeOffset.Now + }), + } + } + } + }; + + metadataClient.BeginWatchingUserPresence(); + }); + + [Test] + public void TestUserActivity() + { + AddStep("idle", () => setPresence(UserStatus.Online, null)); + AddStep("in game", () => setPresence(UserStatus.Online, new UserActivity.InSoloGame(new BeatmapInfo(), rulesetStore.GetRuleset(0)!))); + } + + private void setPresence(UserStatus status, UserActivity? activity, int? userId = null) + { + if (status == UserStatus.Offline) + metadataClient.UserPresenceUpdated(userId ?? panel.User.OnlineID, null); + else + metadataClient.UserPresenceUpdated(userId ?? panel.User.OnlineID, new UserPresence { Status = status, Activity = activity }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneUpdateableTeamFlag.cs b/osu.Game.Tests/Visual/Online/TestSceneUpdateableTeamFlag.cs new file mode 100644 index 000000000000..1813ede83085 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneUpdateableTeamFlag.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 NUnit.Framework; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public partial class TestSceneUpdateableTeamFlag : OsuTestScene + { + [Test] + public void TestHideOnNull() + { + UpdateableTeamFlag flag = null!; + + AddStep("create flag with team", () => Child = flag = new UpdateableTeamFlag(createTeam(), hideOnNull: true) { Width = 300, Height = 150 }); + AddAssert("flag is present", () => flag.IsPresent, () => Is.True); + AddStep("set team to null", () => flag.Team = null); + AddAssert("flag is not present", () => flag.IsPresent, () => Is.False); + } + + [Test] + public void DontHideOnNull() + { + UpdateableTeamFlag flag = null!; + + AddStep("create flag with team", () => Child = flag = new UpdateableTeamFlag(createTeam(), hideOnNull: false) { Width = 300, Height = 150 }); + AddAssert("flag is present", () => flag.IsPresent, () => Is.True); + AddStep("set team to null", () => flag.Team = null); + AddAssert("flag is present", () => flag.IsPresent, () => Is.True); + } + + private static APITeam createTeam() => new APITeam + { + Id = 2, + Name = "mom?", + ShortName = "MOM", + FlagUrl = @"https://assets.ppy.sh/teams/flag/1/b46fb10dbfd8a35dc50e6c00296c0dc6172dffc3ed3d3a4b379277ba498399fe.png", + }; + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index 1c2fdc786096..fd40deab28a8 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -240,6 +240,7 @@ public void TestCustomColourSchemeWithReload() CoverUrl = TestResources.COVER_IMAGE_1, JoinDate = DateTimeOffset.Now.AddDays(-1), LastVisit = DateTimeOffset.Now, + PreviousUsernames = ["ForgetMe", "MySpaceLover", "i once was a man named enis", "mr anderson"], Groups = new[] { new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" }, diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index e453a32652a2..243a22243ea4 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -116,6 +116,33 @@ public void TestOnlyShowOutdatedNoticeBox() }); } + [Test] + public void TestOutdatedNoticeBoxWithSuffixComments() + { + AddStep("Add outdated yaml with comments", () => + { + markdownContainer.Text = @"--- +outdated: true # not sure about the format for ""list of mods"". +---"; + }); + + AddAssert("Outdated notice box visible", () => markdownContainer.ChildrenOfType().Any()); + } + + [Test] + public void TestCommentedOutFrontMatter() + { + AddStep("Add commented out front matter", () => + { + markdownContainer.Text = @"--- +#outdated: true +# outdated: true +---"; + }); + + AddAssert("No notice box visible", () => !markdownContainer.ChildrenOfType().Any()); + } + [Test] public void TestAbsoluteImage() { diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs b/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs new file mode 100644 index 000000000000..b504671c9928 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Playlists; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestSceneAddToPlaylistFooterButton : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private AddToPlaylistFooterButton button = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = button = new AddToPlaylistFooterButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => { } + }; + }); + + [Test] + public void TestAppearDisappear() + { + AddStep("appear", () => button.Appear()); + AddWaitStep("wait for animation", 3); + AddStep("disappear", () => button.Disappear()); + AddWaitStep("wait for animation", 3); + AddStep("appear", () => button.Appear()); + } + } +} diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs new file mode 100644 index 000000000000..52a24ae9f470 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.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.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestSceneFooterButtonFreeModsV2 : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + private readonly FooterButtonFreeMods button; + + public TestSceneFooterButtonFreeModsV2() + { + ModSelectOverlay modSelectOverlay; + Add(modSelectOverlay = new TestModSelectOverlay()); + Add(button = new FooterButtonFreeMods(modSelectOverlay) + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + X = -100, + }); + } + + [Test] + public void TestAllMods() + { + AddStep("all mods", () => button.FreeMods.Value = new OsuRuleset().CreateAllMods().ToArray()); + } + + [Test] + public void TestNoMods() + { + AddStep("no mods", () => button.FreeMods.Value = []); + } + + [Test] + public void TestFreestyle() + { + AddToggleStep("toggle freestyle", v => button.Freestyle.Value = v); + } + + private partial class TestModSelectOverlay : UserModSelectOverlay + { + public TestModSelectOverlay() + : base(OverlayColourScheme.Aquamarine) + { + IsValidMod = _ => true; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreestyleV2.cs b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreestyleV2.cs new file mode 100644 index 000000000000..3eda3bd21da8 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreestyleV2.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestSceneFooterButtonFreestyleV2 : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + public TestSceneFooterButtonFreestyleV2() + { + Add(new FooterButtonFreestyle + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + X = -100, + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs new file mode 100644 index 000000000000..f936bdaaf9df --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestScenePlaylistTray : OnlinePlayTestScene + { + private Room room = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add tray", () => Child = new PlaylistsSongSelect.PlaylistTray(room = new Room()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + [Test] + public void TestAddItem() + { + AddStep("add playlist item", () => + { + room.Playlist = room.Playlist.Append(new PlaylistItem(CreateAPIBeatmap())).ToArray(); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs index 87f65111b004..01156ad4a1a4 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -13,11 +13,11 @@ using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Rulesets; @@ -34,6 +34,7 @@ using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; namespace osu.Game.Tests.Visual.Playlists { @@ -215,6 +216,85 @@ public void TestBeatmapStyle_Reset_OnSelection() AddUntilStep("second beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0])); } + [Test] + public void TestFreestyleSelectAbort() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsScreen playlistsScreen = null!; + + AddStep("load screen", () => LoadScreen(playlistsScreen = new TestPlaylistsScreen(new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for playlist room screen", () => playlistsScreen.Stack.CurrentScreen is PlaylistsRoomSubScreen roomSubScreen && roomSubScreen.IsLoaded); + + AddUntilStep("original beatmap", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0])); + + AddStep("enter freestyle select", () => playlistsScreen.Stack.ChildrenOfType().Single(b => b.IsPresent).TriggerClick()); + AddUntilStep("wait for select screen", () => playlistsScreen.Stack.CurrentScreen is PlaylistsRoomFreestyleSelect selectScreen && selectScreen.CarouselItemsPresented); + + AddStep("select next beatmap", () => InputManager.Key(Key.Down)); + AddStep("abort", () => playlistsScreen.Stack.CurrentScreen.Exit()); + + AddUntilStep("beatmap not changed", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0])); + } + + [Test] + public void TestFreestyleSelect() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsScreen playlistsScreen = null!; + + AddStep("load screen", () => LoadScreen(playlistsScreen = new TestPlaylistsScreen(new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for playlist room screen", () => playlistsScreen.Stack.CurrentScreen is PlaylistsRoomSubScreen roomSubScreen && roomSubScreen.IsLoaded); + + AddUntilStep("original beatmap", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0])); + + AddStep("enter freestyle select", () => playlistsScreen.Stack.ChildrenOfType().Single(b => b.IsPresent).TriggerClick()); + AddUntilStep("wait for select screen", () => playlistsScreen.Stack.CurrentScreen is PlaylistsRoomFreestyleSelect selectScreen && selectScreen.CarouselItemsPresented); + + AddStep("select next beatmap", () => InputManager.Key(Key.Down)); + AddStep("select (beatmap)", () => InputManager.Key(Key.Enter)); + AddStep("select (exit screen)", () => InputManager.Key(Key.Enter)); + + AddUntilStep("beatmap changed", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[1])); + } + /// /// Tests that the ruleset style is reset when the selected item is changed and it's no longer valid. /// @@ -591,30 +671,16 @@ protected override void Dispose(bool isDisposing) private partial class TestPlaylistsScreen : OsuScreen { + public readonly OnlinePlaySubScreenStack Stack; + public TestPlaylistsScreen(PlaylistsRoomSubScreen screen) { - OnlinePlaySubScreenStack stack; - - InternalChildren = new Drawable[] + InternalChild = Stack = new OnlinePlaySubScreenStack { - stack = new OnlinePlaySubScreenStack - { - RelativeSizeAxes = Axes.Both - }, - new BackButton - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - State = { Value = Visibility.Visible }, - Action = () => - { - if (stack.CurrentScreen is not PlaylistsRoomSubScreen) - stack.Exit(); - } - } + RelativeSizeAxes = Axes.Both }; - stack.Push(screen); + Stack.Push(screen); } } diff --git a/osu.Game.Tests/Visual/RankedPlay/RankedPlayTestScene.cs b/osu.Game.Tests/Visual/RankedPlay/RankedPlayTestScene.cs new file mode 100644 index 000000000000..f56b7a7725c3 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/RankedPlayTestScene.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public abstract partial class RankedPlayTestScene : MultiplayerTestScene + { + /// + /// Returns 5 sample s. + /// + protected static APIBeatmap[] GetSampleBeatmaps() + { + using var resourceStream = TestResources.OpenResource("Requests/api-beatmaps-rankedplay.json"); + using var reader = new StreamReader(resourceStream); + + return JsonConvert.DeserializeObject(reader.ReadToEnd())!; + } + + /// + /// A request handler that will resolve api requests to any beatmaps provided by . + /// + public class BeatmapRequestHandler + { + public readonly APIBeatmap[] Beatmaps = GetSampleBeatmaps(); + + public bool HandleRequest(APIRequest request) + { + switch (request) + { + case GetBeatmapRequest beatmapRequest: + var beatmap = Beatmaps.FirstOrDefault(it => it.OnlineID == beatmapRequest.OnlineID); + + if (beatmap != null) + { + beatmapRequest.TriggerSuccess(beatmap); + return true; + } + + break; + + case GetBeatmapsRequest beatmapsRequest: + beatmapsRequest.TriggerSuccess(new GetBeatmapsResponse + { + Beatmaps = beatmapsRequest + .BeatmapIds + .Select(id => Beatmaps.FirstOrDefault(it => it.OnlineID == id)) + .ToList() + }); + + return true; + } + + return false; + } + } + + public class RevealedRankedPlayCardWithPlaylistItem : RankedPlayCardWithPlaylistItem + { + public RevealedRankedPlayCardWithPlaylistItem(APIBeatmap beatmap, RankedPlayCardItem? card = null) + : base(card ?? new RankedPlayCardItem()) + { + PlaylistItem.Value = new MultiplayerPlaylistItem(new PlaylistItem(beatmap)); + } + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneDiscardScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneDiscardScreen.cs new file mode 100644 index 000000000000..f3e39796ac19 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneDiscardScreen.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneDiscardScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + + AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneEndedScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneEndedScreen.cs new file mode 100644 index 000000000000..8a7f22c8229e --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneEndedScreen.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 NUnit.Framework; +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneEndedScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + } + + [Test] + public void TestVictory() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Ended, s => + { + s.WinningUserId = API.LocalUser.Value.OnlineID; + s.Users[API.LocalUser.Value.OnlineID].RatingAfter = 1520; + s.Users[2].RatingAfter = 1480; + }).WaitSafely()); + } + + [Test] + public void TestDefeat() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Ended, s => + { + s.WinningUserId = 2; + s.Users[API.LocalUser.Value.OnlineID].RatingAfter = 1480; + s.Users[2].RatingAfter = 1520; + }).WaitSafely()); + } + + [Test] + public void TestDraw() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Ended, s => + { + s.Users[API.LocalUser.Value.OnlineID].RatingAfter = 1480; + s.Users[2].RatingAfter = 1520; + }).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneGameplayWarmupScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneGameplayWarmupScreen.cs new file mode 100644 index 000000000000..125cfd97811a --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneGameplayWarmupScreen.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneGameplayWarmupScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var beatmap = new TestBeatmap(Ruleset.Value).BeatmapInfo; + beatmap.StarRating = 2; + + var room = CreateDefaultRoom(MatchType.RankedPlay); + room.Playlist = + [ + new PlaylistItem(beatmap) + { + RulesetID = Ruleset.Value.OnlineID + } + ]; + + JoinRoom(room); + }); + + WaitForJoined(); + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + AddStep("play card", () => MultiplayerClient.PlayCard(new RankedPlayCardItem())); + + AddStep("set warmup state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.GameplayWarmup).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs new file mode 100644 index 000000000000..304b6c223825 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneHandReplay.cs @@ -0,0 +1,140 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneHandReplay : MultiplayerTestScene + { + private PlayerHandOfCards playerHand = null!; + private OpponentHandOfCards opponentHand = null!; + private TestHandReplayRecorder recorder = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("setup", () => + { + var cards = Enumerable.Range(0, 5) + .Select(_ => new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())) + .ToArray(); + + Children = + [ + playerHand = new PlayerHandOfCards + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + SelectionMode = HandSelectionMode.Multiple + }, + opponentHand = new OpponentHandOfCards + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new HandReplayPlayer(API.LocalUser.Value.OnlineID, opponentHand), + recorder = new TestHandReplayRecorder(playerHand) + { + FlushInterval = flushInterval, + RecordInterval = recordInterval, + } + ]; + + foreach (var card in cards) + { + playerHand.AddCard(card); + opponentHand.AddCard(card); + } + }); + } + + private double flushInterval = 1000; + private double recordInterval = 25; + private double fixedLatency; + private double maxLatency; + + [Test] + public void TestCardHandReplay() + { + AddSliderStep("record interval", 0.0, 1000.0, 25.0, value => + { + recordInterval = value; + recreateRecorder(); + }); + AddSliderStep("flush interval", 0.0, 5000.0, 1000.0, value => + { + flushInterval = value; + recreateRecorder(); + }); + AddSliderStep("latency", 0.0, 5000.0, 0.0, value => + { + fixedLatency = value; + recreateRecorder(); + }); + AddSliderStep("randomize latency", 0.0, 5000.0, 0.0, value => + { + maxLatency = value; + recreateRecorder(); + }); + } + + private void recreateRecorder() + { + if (recorder.IsNotNull()) + { + Remove(recorder, true); + Add(recorder = new TestHandReplayRecorder(playerHand) + { + FlushInterval = flushInterval, + RecordInterval = recordInterval, + FixedLatency = fixedLatency, + RandomLatency = maxLatency, + }); + } + } + + private partial class TestHandReplayRecorder(PlayerHandOfCards handOfCards) : HandReplayRecorder(handOfCards) + { + private double lastSendTime; + + public double FixedLatency; + + public double RandomLatency; + + protected override void Flush(RankedPlayCardHandReplayFrame[] frames) + { + double sendTime = Math.Max(lastSendTime, Time.Current + FixedLatency + RNG.NextDouble(RandomLatency)); + + lastSendTime = sendTime; + + Scheduler.AddDelayed(() => base.Flush(frames), sendTime - Time.Current); + } + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneIntroScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneIntroScreen.cs new file mode 100644 index 000000000000..05ca5673ffbd --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneIntroScreen.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro; +using osu.Game.Tests.Visual.Matchmaking; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneIntroScreen : MatchmakingTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + IntroScreen introScreen = null!; + + AddStep("Add screen", () => Child = introScreen = new IntroScreen()); + + AddStep("play animation", () => introScreen.PlayIntroSequence( + new UserWithRating(new APIUser + { + Id = 2, + Username = "User 1", + CoverUrl = "https://assets.ppy.sh/user-profile-covers/13845312/53e4eda7ad3ce41f0990c041179d8ab5d553fef988835f346a8d8da0482506ec.png" + }, 1234), + new UserWithRating(new APIUser + { + Id = 3, + Username = "User 2", + CoverUrl = "https://assets.ppy.sh/user-profile-covers/14102976/10144df2f1c6fb2101726e0f89087a6061bc75755d88e59a9faf2c84034f2c71.jpeg" + }, 1234), + 6.3f + )); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneOpponentPickScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneOpponentPickScreen.cs new file mode 100644 index 000000000000..838a49d2558f --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneOpponentPickScreen.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneOpponentPickScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + + AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestScenePickScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestScenePickScreen.cs new file mode 100644 index 000000000000..042a56adf8b5 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestScenePickScreen.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestScenePickScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + + AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs b/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs new file mode 100644 index 000000000000..a567079e20d1 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestScenePlayerCardHand.cs @@ -0,0 +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.Linq; +using Humanizer; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestScenePlayerCardHand : OsuManualInputManagerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private PlayerHandOfCards handOfCards = null!; + + [BackgroundDependencyLoader] + private void load() + { + Child = handOfCards = new PlayerHandOfCards + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + }; + } + + [Test] + public void TestSingleSelectionMode() + { + AddStep("add cards", () => + { + handOfCards.Clear(); + for (int i = 0; i < 5; i++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + AddStep("single selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Single); + + AddStep("click first card", () => handOfCards.Cards.First().TriggerClick()); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.First().Item])); + + AddStep("click second card", () => handOfCards.Cards.ElementAt(1).TriggerClick()); + AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item])); + + AddStep("click second card again", () => handOfCards.Cards.ElementAt(1).TriggerClick()); + AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item])); + } + + [Test] + public void TestMultiSelectionMode() + { + AddStep("add cards", () => + { + handOfCards.Clear(); + for (int i = 0; i < 5; i++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + AddStep("multi selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Multiple); + + AddStep("click first card", () => handOfCards.Cards.First().TriggerClick()); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.First().Item])); + + AddStep("click second card", () => handOfCards.Cards.ElementAt(1).TriggerClick()); + AddAssert("both cards selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item, handOfCards.Cards.ElementAt(1).Item])); + + AddStep("click second card again", () => handOfCards.Cards.ElementAt(1).TriggerClick()); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item])); + } + + [Test] + public void TestCardCount() + { + for (int i = 1; i <= 8; i++) + { + int numCards = i; + + AddStep($"{i} {"cards".Pluralize(i == 1)}", () => + { + handOfCards.Clear(); + + for (int j = 0; j < numCards; j++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + } + } + + [Test] + public void TestKeyboardSelectionSingleSelection() + { + bool playActionTriggered = false; + + AddStep("add cards", () => + { + playActionTriggered = false; + handOfCards.PlayCardAction = () => playActionTriggered = true; + + handOfCards.Clear(); + for (int i = 0; i < 5; i++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + AddStep("single selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Single); + + for (int i = 0; i < 5; i++) + { + int i1 = i; + Key key = Key.Number1 + i; + + AddStep($"key {i + 1}", () => InputManager.Key(key)); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(i1).Item])); + } + + AddStep("right arrow", () => InputManager.Key(Key.Right)); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item])); + + AddStep("right arrow", () => InputManager.Key(Key.Right)); + AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item])); + + AddStep("left arrow", () => InputManager.Key(Key.Left)); + AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item])); + + AddStep("left arrow", () => InputManager.Key(Key.Left)); + AddAssert("last card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(^1).Item])); + + AddStep("space", () => InputManager.Key(Key.Space)); + AddAssert("play action triggered", () => playActionTriggered); + } + + [Test] + public void TestKeyboardSelectionMultiSelection() + { + AddStep("add cards", () => + { + handOfCards.Clear(); + for (int i = 0; i < 5; i++) + handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem())); + }); + AddStep("multi selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Multiple); + + for (int i = 0; i < 5; i++) + { + int i1 = i; + Key key = Key.Number1 + i; + + AddStep($"key {i + 1}", () => InputManager.Key(key)); + AddAssert("card hovered", () => handOfCards.Cards.ElementAt(i1).CardHovered); + + AddAssert("card not selected", () => !handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item)); + AddStep("space", () => InputManager.Key(Key.Space)); + AddAssert("card selected", () => handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item)); + } + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayBackground.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayBackground.cs new file mode 100644 index 000000000000..a181abc1c656 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayBackground.cs @@ -0,0 +1,62 @@ +// 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.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayBackground : OsuTestScene + { + private readonly RankedPlayBackground background; + + private readonly Bindable gradientOuter = new Bindable(Color4Extensions.FromHex("AC6D97")); + private readonly Bindable gradientInner = new Bindable(Color4Extensions.FromHex("544483")); + private readonly Bindable dots = new Bindable(Color4Extensions.FromHex("D56CF6")); + + public TestSceneRankedPlayBackground() + { + Children = + [ + background = new RankedPlayBackground { RelativeSizeAxes = Axes.Both }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = + [ + new BasicColourPicker + { + Scale = new Vector2(0.4f), + Current = gradientOuter, + }, + new BasicColourPicker + { + Scale = new Vector2(0.4f), + Current = gradientInner, + }, + new BasicColourPicker + { + Scale = new Vector2(0.4f), + Current = dots, + } + ] + } + ]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + gradientOuter.BindValueChanged(e => background.GradientOutside = e.NewValue, true); + gradientInner.BindValueChanged(e => background.GradientInside = e.NewValue, true); + dots.BindValueChanged(e => background.DotsColour = e.NewValue, true); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCard.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCard.cs new file mode 100644 index 000000000000..54fc56a4f9a5 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCard.cs @@ -0,0 +1,222 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Cursor; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayCard : RankedPlayTestScene + { + protected override Container Content { get; } + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + [Cached] + private readonly CardDetailsOverlayContainer overlayContainer; + + [Cached] + private readonly SongPreviewParticleContainer particleContainer; + + private readonly BeatmapRequestHandler requestHandler = new BeatmapRequestHandler(); + + public TestSceneRankedPlayCard() + { + base.Content.AddRange(new Drawable[] + { + new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = Content = new Container + { + RelativeSizeAxes = Axes.Both, + } + }, + overlayContainer = new CardDetailsOverlayContainer(), + particleContainer = new SongPreviewParticleContainer(), + }); + } + + [Test] + public void TestCards() + { + AddStep("add cards", () => + { + FillFlowContainer flow; + + Child = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + Width = 800f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(10), + }; + + for (int i = 0; i < 10; i++) + { + var beatmap = CreateAPIBeatmap(); + + beatmap.BeatmapSet!.Ratings = Enumerable.Range(0, 11).ToArray(); + beatmap.BeatmapSet!.RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ]; + + beatmap.TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ]; + + beatmap.FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(x => x % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(x => x % 12 - 6).ToArray(), + }; + + beatmap.StarRating = i + 1; + + flow.Add(new RankedPlayCardContent(beatmap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.2f), + }); + } + }); + } + + [Test] + public void TestCardHand() + { + AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest); + + AddStep("add cards", () => + { + PlayerHandOfCards handOfCards; + + Child = handOfCards = new PlayerHandOfCards + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + SelectionMode = HandSelectionMode.Single + }; + + foreach (var beatmap in requestHandler.Beatmaps) + { + handOfCards.AddCard(new RevealedRankedPlayCardWithPlaylistItem(beatmap)); + } + }); + } + + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + + [Test] + public void TestRulesets() + { + var rulesets = rulesetStore.AvailableRulesets.Where(it => it.OnlineID >= 0); + + foreach (var ruleset in rulesets) + { + AddStep(ruleset.ShortName, () => + { + FillFlowContainer flow; + + Child = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + Width = 800f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(10), + }; + + for (int i = 0; i < 10; i++) + { + var beatmap = CreateAPIBeatmap(ruleset); + + beatmap.BeatmapSet!.Ratings = Enumerable.Range(0, 11).ToArray(); + beatmap.BeatmapSet!.RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ]; + + beatmap.TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ]; + + beatmap.FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(x => x % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(x => x % 12 - 6).ToArray(), + }; + + beatmap.StarRating = i + 1; + + flow.Add(new RankedPlayCardContent(beatmap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.2f), + }); + } + }); + } + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCornerPiece.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCornerPiece.cs new file mode 100644 index 000000000000..f467540cb8e8 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayCornerPiece.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 NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayCornerPiece : MultiplayerTestScene + { + private readonly Bindable visibility = new Bindable(Visibility.Visible); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add children", () => + { + Children = + [ + new RankedPlayCornerPiece(RankedPlayColourScheme.Blue, Anchor.BottomLeft) + { + State = { BindTarget = visibility }, + Child = new RankedPlayUserDisplay(2, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + } + }, + new RankedPlayCornerPiece(RankedPlayColourScheme.Red, Anchor.TopRight) + { + State = { BindTarget = visibility }, + Child = new RankedPlayUserDisplay(2, Anchor.TopRight, RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + } + }, + ]; + }); + } + + [Test] + public void TestCornerPieces() + { + AddStep("show", () => visibility.Value = Visibility.Visible); + AddStep("hide", () => visibility.Value = Visibility.Hidden); + AddSliderStep("health", 0, 1_000_000, 1_000_000, value => + { + foreach (var d in this.ChildrenOfType()) + { + d.Health.Value = value; + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs new file mode 100644 index 000000000000..6446ec8f081c --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayScreen.cs @@ -0,0 +1,176 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayScreen : RankedPlayTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + } + + [Test] + public void TestIntroStage() + { + AddStep("set round warmup phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.RoundWarmup, s => s.StarRating = 6.3f).WaitSafely()); + } + + [Test] + public void TestDiscardCardsStage() + { + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + + AddWaitStep("wait", 3); + + for (int i = 0; i < 3; i++) + { + int i2 = i; + AddStep($"click card {i2}", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(i2)); + InputManager.Click(MouseButton.Left); + }); + } + + AddWaitStep("wait", 3); + + AddStep("click discard button", () => + { + var button = screen.ChildrenOfType().Single(it => it.Name == "Discard Button"); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + AddWaitStep("wait", 13); + AddStep("set finish discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.FinishCardDiscard).WaitSafely()); + } + + [Test] + public void TestAddRemoveCards() + { + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + + for (int i = 0; i < 3; i++) + AddStep("add card", () => MultiplayerClient.RankedPlayAddCards([new RankedPlayCardItem()]).WaitSafely()); + + for (int i = 0; i < 3; i++) + AddStep("remove card", () => MultiplayerClient.RankedPlayRemoveCards(hand => [hand[0]]).WaitSafely()); + } + + [Test] + public void TestRevealCards() + { + var requestHandler = new BeatmapRequestHandler(); + + AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest); + + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + + for (int i = 0; i < 3; i++) + { + int i2 = i; + AddStep("reveal card", () => MultiplayerClient.RankedPlayRevealCard(hand => hand[i2], new MultiplayerPlaylistItem + { + ID = i2, + BeatmapID = requestHandler.Beatmaps[i2].OnlineID + }).WaitSafely()); + } + } + + [Test] + public void TestPlayCardDirect() + { + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely()); + AddWaitStep("wait", 3); + AddStep("play card", () => MultiplayerClient.PlayCard(hand => hand[0]).WaitSafely()); + } + + [Test] + public void TestDiscardCardsDirect() + { + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + AddWaitStep("wait", 3); + AddStep("discard cards", () => MultiplayerClient.DiscardCards(hand => hand.Take(3)).WaitSafely()); + AddWaitStep("wait", 13); + AddStep("set finish discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.FinishCardDiscard).WaitSafely()); + } + + [Test] + public void TestPlayStage() + { + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely()); + AddUntilStep("wait until cards are present", () => this.ChildrenOfType().Count() == 5); + + for (int i = 0; i < 3; i++) + { + int i2 = i; + AddStep($"click card {i2}", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(i2)); + InputManager.Click(MouseButton.Left); + }); + } + + AddWaitStep("wait", 3); + + AddStep("click play button", () => + { + var button = screen + .ChildrenOfType() + .First(it => it.Selected) + .ChildrenOfType() + .First(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + } + + [Test] + public void TestOtherPlaysCard() + { + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely()); + AddWaitStep("wait", 5); + AddStep("play beatmap", () => MultiplayerClient.PlayUserCard(2, hand => hand[0]).WaitSafely()); + AddStep("reveal card", () => MultiplayerClient.RankedPlayRevealUserCard(2, hand => hand[0], new MultiplayerPlaylistItem + { + ID = 0, + BeatmapID = 0 + }).WaitSafely()); + } + + [Test] + public void TestHealthChange() + { + AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely()); + AddWaitStep("wait", 5); + AddStep("change player 1 health", () => MultiplayerClient.RankedPlayChangeUserState(MultiplayerClient.LocalUser!.UserID, state => state.Life = 250_000).WaitSafely()); + AddWaitStep("wait", 5); + AddStep("change player 2 health", () => MultiplayerClient.RankedPlayChangeUserState(2, state => state.Life = 250_000).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayUserDisplay.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayUserDisplay.cs new file mode 100644 index 000000000000..f7cc9885c79a --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayUserDisplay.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.Bindables; +using osu.Framework.Graphics; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayUserDisplay : MultiplayerTestScene + { + private readonly BindableInt health = new BindableInt + { + MaxValue = 1_000_000, + MinValue = 0, + Value = 1_000_000, + }; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add display", () => Child = new RankedPlayUserDisplay(2, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(256, 72), + Health = { BindTarget = health } + }); + } + + [Test] + public void TesUserDisplay() + { + AddStep("blue color scheme", () => Child = new RankedPlayUserDisplay(2, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(256, 72), + Health = { BindTarget = health } + }); + + AddStep("red color scheme", () => Child = new RankedPlayUserDisplay(2, Anchor.BottomLeft, RankedPlayColourScheme.Red) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(256, 72), + Health = { BindTarget = health } + }); + + AddSliderStep("health", 0, 1_000_000, 1_000_000, value => health.Value = value); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneResultsScreen.cs new file mode 100644 index 000000000000..00eb63751205 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneResultsScreen.cs @@ -0,0 +1,231 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneResultsScreen : MultiplayerTestScene + { + private RankedPlayScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay))); + WaitForJoined(); + + AddStep("add other user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2))); + + AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + AddUntilStep("screen loaded", () => screen.IsLoaded); + + setupRequestHandler(); + } + + [Test] + public void TestBasic() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Results, state => + { + int losingPlayer = state.Users.Keys.First(); + + foreach (var (id, userInfo) in state.Users) + { + if (id == losingPlayer) + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 123_456, + Damage = 123_456, + OldLife = 500_000, + NewLife = 500_000 - 123_456, + }; + + userInfo.Life = 500_000 - 123_456; + } + else + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 0, + Damage = 0, + OldLife = 1_000_000, + NewLife = 1_000_000, + }; + } + } + }).WaitSafely()); + } + + [Test] + public void TestMultiplier() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Results, state => + { + int losingPlayer = state.Users.Keys.First(); + + state.DamageMultiplier = 2; + + foreach (var (id, userInfo) in state.Users) + { + if (id == losingPlayer) + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 123_456, + Damage = 123_456 * 2, + OldLife = 1_000_000, + NewLife = 1_000_000 - 123_456 * 2, + }; + + userInfo.Life = 1_000_000 - 123_456 * 2; + } + else + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 0, + Damage = 0, + OldLife = 1_000_000, + NewLife = 1_000_000, + }; + } + } + }).WaitSafely()); + } + + [Test] + public void TestMissingScores() + { + AddStep("setup request handler", () => + { + Func? defaultRequestHandler = ((DummyAPIAccess)API).HandleRequest; + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case IndexPlaylistScoresRequest index: + index.TriggerSuccess(new IndexedMultiplayerScores()); + return true; + + default: + return defaultRequestHandler?.Invoke(request) ?? false; + } + }; + }); + + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Results, state => + { + int losingPlayer = state.Users.Keys.First(); + + state.DamageMultiplier = 2; + + foreach (var (id, userInfo) in state.Users) + { + if (id == losingPlayer) + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 123_456, + Damage = 123_456 * 2, + OldLife = 1_000_000, + NewLife = 1_000_000 - 123_456 * 2, + }; + } + else + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 0, + Damage = 0, + OldLife = 1_000_000, + NewLife = 1_000_000, + }; + } + } + }).WaitSafely()); + } + + private void setupRequestHandler() + { + AddStep("setup request handler", () => + { + Func? defaultRequestHandler = ((DummyAPIAccess)API).HandleRequest; + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case IndexPlaylistScoresRequest index: + var result = new IndexedMultiplayerScores(); + + foreach (int userId in new[] { 2, API.LocalUser.Value.OnlineID }) + { + result.Scores.Add(new MultiplayerScore + { + ID = userId, + Accuracy = RNG.NextSingle(), + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH), + MaxCombo = RNG.Next(1000), + TotalScore = userId == 2 ? 750_000 : 750_000 - 123_456, + Statistics = new Dictionary + { + [HitResult.Miss] = 1, + [HitResult.Meh] = 50, + [HitResult.Ok] = 100, + [HitResult.Good] = 200, + [HitResult.Great] = 300, + [HitResult.Perfect] = 320, + [HitResult.SmallTickHit] = 50, + [HitResult.SmallTickMiss] = 25, + [HitResult.LargeTickHit] = 100, + [HitResult.LargeTickMiss] = 50, + [HitResult.SmallBonus] = 10, + [HitResult.LargeBonus] = 50 + }, + MaximumStatistics = new Dictionary + { + [HitResult.Perfect] = 971, + [HitResult.SmallTickHit] = 75, + [HitResult.LargeTickHit] = 150, + [HitResult.SmallBonus] = 10, + [HitResult.LargeBonus] = 50, + }, + User = new APIUser + { + Id = userId, + Username = $"user {userId}", + } + }); + } + + index.TriggerSuccess(result); + return true; + + default: + return defaultRequestHandler?.Invoke(request) ?? false; + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneSongPreview.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneSongPreview.cs new file mode 100644 index 000000000000..363082f668cc --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneSongPreview.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneSongPreview : RankedPlayTestScene + { + private readonly Bindable previewEnabled = new BindableBool(true); + + private readonly BeatmapRequestHandler requestHandler = new BeatmapRequestHandler(); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest); + + AddStep("add cards", () => + { + PlayerHandOfCards handOfCards; + + Child = handOfCards = new PlayerHandOfCards + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Size = new Vector2(0.5f), + }; + + foreach (var beatmap in requestHandler.Beatmaps.Take(3)) + { + handOfCards.AddCard(new RevealedRankedPlayCardWithPlaylistItem(beatmap), handCard => + { + handCard.Card.SongPreviewEnabled.BindTarget = previewEnabled; + }); + } + }); + + AddUntilStep("load tracks", () => this.ChildrenOfType().All(card => card.PreviewTrackLoaded)); + } + + [Test] + public void TestSongPreview() + { + AddStep("move mouse to first card", () => InputManager.MoveMouseTo(getCard(0))); + + AddAssert("first track running", () => getCard(0).PreviewTrackRunning); + AddAssert("only one track running", () => this.ChildrenOfType().Count(c => c.PreviewTrackRunning) == 1); + + AddStep("move mouse to second card", () => InputManager.MoveMouseTo(getCard(1))); + + AddAssert("second track running", () => getCard(1).PreviewTrackRunning); + AddAssert("only one track running", () => this.ChildrenOfType().Count(c => c.PreviewTrackRunning) == 1); + + AddStep("disable preview", () => previewEnabled.Value = false); + + AddAssert("no tracks running", () => !this.ChildrenOfType().Any(c => c.PreviewTrackRunning)); + + AddStep("move mouse to third card", () => InputManager.MoveMouseTo(getCard(2))); + + AddAssert("no tracks running", () => !this.ChildrenOfType().Any(c => c.PreviewTrackRunning)); + + AddStep("enable preview", () => previewEnabled.Value = true); + + AddAssert("third track running", () => getCard(2).PreviewTrackRunning); + } + + private RankedPlayCard getCard(int index) => this.ChildrenOfType().ElementAt(index); + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index eade5aaf5dad..df9dbf90eebe 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -67,7 +67,7 @@ public void TestMapWithUnknownMapper() AddAssert("mapped by text not present", () => this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text.ToString(), "mapped", "by"))); - AddAssert("play time displayed", () => this.ChildrenOfType().Any()); + AddAssert("play time displayed", () => this.ChildrenOfType().Any()); } [Test] @@ -137,7 +137,7 @@ public void TestWithDefaultDate() showPanel(score); }); - AddAssert("play time not displayed", () => !this.ChildrenOfType().Any()); + AddAssert("play time not displayed", () => !this.ChildrenOfType().Any()); } [Test] diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs index e75c831a7fd1..266adf1ee855 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs @@ -19,8 +19,8 @@ using osu.Game.Online.Leaderboards; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select.Leaderboards; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Ranking diff --git a/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs b/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs index 2fc5378ba144..ceca59dccf21 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs @@ -11,6 +11,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Configuration; +using osu.Game.Overlays; using osu.Game.Overlays.Settings.Sections.Audio; using osu.Game.Scoring; using osu.Game.Tests.Visual.Ranking; @@ -25,6 +26,9 @@ public partial class TestSceneAudioOffsetAdjustControl : OsuTestScene [Cached] private SessionAverageHitErrorTracker tracker = new SessionAverageHitErrorTracker(); + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + private Container content = null!; protected override Container Content => content; diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index 4cad283833aa..d077294dd510 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays; +using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections.Input; using osu.Game.Rulesets.Taiko; using osuTK.Input; @@ -67,7 +68,16 @@ public void TestBindingSingleModifier() scrollToAndStartBinding("Increase volume"); AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); - checkBinding("Increase volume", "LShift"); + checkBinding("Increase volume", "Shift"); + } + + [Test] + public void TestRulesetBindingSingleModifier() + { + scrollToAndStartBinding("Left button"); + AddStep("press left shift", () => InputManager.Key(Key.ShiftLeft)); + AddStep("release left shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + checkBinding("Left button", "LShift"); } [Test] @@ -77,7 +87,7 @@ public void TestBindingSingleKeyWithModifier() AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); AddStep("press k", () => InputManager.Key(Key.K)); AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); - checkBinding("Increase volume", "LShift-K"); + checkBinding("Increase volume", "Shift-K"); } [Test] @@ -121,7 +131,7 @@ public void TestClickTwiceOnClearButton() AddStep("schedule button clicks", () => { - var clearButton = firstRow.ChildrenOfType().Single(); + var clearButton = firstRow.ChildrenOfType().Single(); InputManager.MoveMouseTo(clearButton); @@ -179,7 +189,7 @@ void clickClearButton() { AddStep("click clear button", () => { - var clearButton = multiBindingRow.ChildrenOfType().Single(); + var clearButton = multiBindingRow.ChildrenOfType().Single(); InputManager.MoveMouseTo(clearButton); InputManager.Click(MouseButton.Left); @@ -202,16 +212,16 @@ public void TestSingleBindingResetButton() InputManager.ReleaseKey(Key.P); }); - AddUntilStep("restore button shown", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha > 0); + AddUntilStep("restore button shown", () => settingsKeyBindingRow.ChildrenOfType().First().Alpha > 0); AddStep("click reset button for bindings", () => { - var resetButton = settingsKeyBindingRow.ChildrenOfType>().First(); + var resetButton = settingsKeyBindingRow.ChildrenOfType().First(); resetButton.TriggerClick(); }); - AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); + AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType().First().Alpha == 0); AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.Value.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); @@ -232,7 +242,7 @@ public void TestResetAllBindingsButton() InputManager.ReleaseKey(Key.P); }); - AddUntilStep("restore button shown", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha > 0); + AddUntilStep("restore button shown", () => settingsKeyBindingRow.ChildrenOfType().First().Alpha > 0); AddStep("click reset button for bindings", () => { @@ -241,7 +251,7 @@ public void TestResetAllBindingsButton() resetButton.TriggerClick(); }); - AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); + AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType().First().Alpha == 0); AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.Value.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); @@ -386,7 +396,7 @@ public void TestBindingConflictCausedByResetToDefaultOfSingleRow() AddStep("clear binding", () => { var row = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == "Left (centre)")); - row.ChildrenOfType().Single().TriggerClick(); + row.ChildrenOfType().Single().TriggerClick(); }); scrollToAndStartBinding("Left (rim)"); AddStep("bind M1", () => InputManager.Click(MouseButton.Left)); @@ -394,7 +404,7 @@ public void TestBindingConflictCausedByResetToDefaultOfSingleRow() AddStep("reset Left (centre) to default", () => { var row = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == "Left (centre)")); - row.ChildrenOfType>().Single().TriggerClick(); + row.ChildrenOfType().Single().TriggerClick(); }); KeyBindingConflictPopover popover = null; @@ -450,7 +460,7 @@ public void TestResettingRowCannotConflictWithItself() AddStep("revert row to default", () => { var row = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == "Left (centre)")); - InputManager.MoveMouseTo(row.ChildrenOfType>().Single()); + InputManager.MoveMouseTo(row.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddWaitStep("wait a bit", 3); @@ -462,7 +472,7 @@ private void clearBinding() AddStep("clear binding", () => { var row = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == "Left (centre)")); - row.ChildrenOfType().Single().TriggerClick(); + row.ChildrenOfType().Single().TriggerClick(); }); } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingRow.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingRow.cs index ff996a9ca118..09a46ba88c51 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingRow.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingRow.cs @@ -10,6 +10,7 @@ using osu.Framework.Testing; using osu.Game.Input.Bindings; using osu.Game.Overlays; +using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections.Input; namespace osu.Game.Tests.Visual.Settings @@ -45,7 +46,7 @@ public void TestChangesAfterConstruction() row.KeyBindings.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.Escape))); row.KeyBindings.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.ExtraMouseButton1))); }); - AddUntilStep("revert to default button not shown", () => row.ChildrenOfType>().Single().Alpha, () => Is.Zero); + AddUntilStep("revert to default button not shown", () => row.ChildrenOfType().Single().Alpha, () => Is.Zero); AddStep("change key bindings", () => { @@ -54,7 +55,7 @@ public void TestChangesAfterConstruction() row.KeyBindings.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.Z))); row.KeyBindings.Add(new RealmKeyBinding(GlobalAction.Back, new KeyCombination(InputKey.I))); }); - AddUntilStep("revert to default button not shown", () => row.ChildrenOfType>().Single().Alpha, () => Is.Not.Zero); + AddUntilStep("revert to default button not shown", () => row.ChildrenOfType().Single().Alpha, () => Is.Not.Zero); } } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItemV2.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItemV2.cs new file mode 100644 index 000000000000..d460ea0078e7 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItemV2.cs @@ -0,0 +1,271 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.Settings +{ + public partial class TestSceneSettingsItemV2 : ThemeComparisonTestScene + { + private readonly Bindable note = new Bindable(); + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private FormSliderBar sliderBar = null!; + + private SearchContainer searchContainer = null!; + + public TestSceneSettingsItemV2() + : base(false) + { + } + + protected override Drawable CreateContent() + { + return new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new BackgroundBox + { + RelativeSizeAxes = Axes.Both, + }, + new OsuContextMenuContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 400, + RelativeSizeAxes = Axes.Y, + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = searchContainer = new SearchContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Spacing = new Vector2(7), + Padding = new MarginPadding { Vertical = 10 }, + Children = new[] + { + new SettingsItemV2(new FormTextBox + { + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + Current = { Value = string.Empty, Default = string.Empty } + }), + new SettingsItemV2(new FormTextBox + { + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + Current = { Value = string.Empty, Default = string.Empty, Disabled = true } + }), + new SettingsItemV2(new FormNumberBox(allowDecimals: true) + { + Caption = "Number", + HintText = "Insert your favourite number", + PlaceholderText = "Mine is 42!", + Current = { Value = string.Empty, Default = string.Empty } + }), + new SettingsItemV2(new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + }) + { + Note = { BindTarget = note }, + }, + new SettingsItemV2(new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + Current = { Disabled = true }, + }), + new SettingsItemV2(new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + Current = { Value = true, Disabled = true }, + }), + new SettingsItemV2(new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + }), + new SettingsItemV2(new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + Current = { Disabled = true }, + }), + new SettingsItemV2(new FormEnumDropdown + { + Caption = "Dropdown with many items", + HintText = EditorSetupStrings.CountdownDescription, + }) + { + Note = { BindTarget = note }, + }, + new SettingsItemV2(sliderBar = new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + }), + new SettingsItemV2(new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + Disabled = true, + }, + TransferValueOnCommit = true, + }), + new SettingsItemV2(new FormSliderBar + { + Caption = "Slider without revert button", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + }) + { + ShowRevertToDefaultButton = false + }, + new SettingsItemV2(new FormSliderBar + { + Caption = "Slider with classic default", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + }) + { + ApplyClassicDefault = c => ((IHasCurrentValue)c).Current.Value = 2, + }, + }, + }, + }, + } + }, + }, + }; + } + + [Test] + public void TestDisplay() + { + AddStep("display", () => CreateThemedContent(OverlayColourScheme.Purple)); + } + + [Test] + public void TestNote() + { + AddStep("set informational note", () => note.Value = new SettingsNote.Data(LayoutSettingsStrings.OsuIsRunningExclusiveFullscreen.ToString(), SettingsNote.Type.Informational)); + AddStep("set warning note", + () => note.Value = new SettingsNote.Data( + "Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. “2x refresh rate” is recommended.", + SettingsNote.Type.Warning)); + AddStep("set critical note", + () => note.Value = new SettingsNote.Data( + "You have done something so horrible in the game settings to the point we have invented a new note type for this. Look at it, it's in red. It's worse than yellow.", + SettingsNote.Type.Critical)); + AddStep("clear note", () => note.Value = null); + } + + [Test] + public void TestClassicDefault() + { + AddStep("modify irrelevant setting", () => sliderBar.Current.Value = 4); + AddStep("apply classic defaults", () => this.ChildrenOfType().Where(i => i.HasClassicDefault).ForEach(s => s.ApplyClassicDefault())); + AddStep("apply regular defaults", () => this.ChildrenOfType().Where(i => i.HasClassicDefault).ForEach(s => s.ApplyDefault())); + AddStep("set classic filter", () => searchContainer.SearchTerm = SettingsItemV2.CLASSIC_DEFAULT_SEARCH_TERM); + AddStep("apply classic defaults", () => this.ChildrenOfType().Where(i => i.HasClassicDefault).ForEach(s => s.ApplyClassicDefault())); + AddStep("apply regular defaults", () => this.ChildrenOfType().Where(i => i.HasClassicDefault).ForEach(s => s.ApplyDefault())); + AddStep("set no filter", () => searchContainer.SearchTerm = string.Empty); + AddAssert("irrelevant setting left out", () => sliderBar.Current.Value, () => Is.EqualTo(4)); + } + + /// + /// Ensures that the reset to default button uses the correct implementation of IsDefault to determine whether it should be shown or not. + /// Values have been chosen so that after being set, Value != Default (but they are close enough that the difference is negligible compared to Precision). + /// + [TestCase(4.2f)] + [TestCase(9.9f)] + public void TestRestoreDefaultValueButtonPrecision(float initialValue) + { + BindableFloat current = null!; + SettingsRevertToDefaultButton revertToDefaultButton = null!; + + AddStep("set current bindable", () => sliderBar.Current = current = new BindableFloat(initialValue) + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + }); + + AddStep("retrieve restore default button", () => revertToDefaultButton = sliderBar.FindClosestParent().ChildrenOfType().Single()); + + AddAssert("restore button hidden", () => revertToDefaultButton.X == 0); + + AddStep("change value to next closest", () => sliderBar.Current.Value += current.Precision * 0.6f); + AddUntilStep("restore button shown", () => revertToDefaultButton.X > 0); + + AddStep("restore default", () => sliderBar.Current.SetDefault()); + AddUntilStep("restore button hidden", () => revertToDefaultButton.X == 0); + } + + private partial class BackgroundBox : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Background4; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs index e9f70180e1a2..194e4301c232 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Input.Handlers; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Testing; using osu.Framework.Utils; @@ -69,7 +70,7 @@ public void TestWideAspectRatioValidity() { AddStep("Test with wide tablet", () => tabletHandler.SetTabletSize(new Vector2(160, 100))); - AddStep("Reset to full area", () => settings.ChildrenOfType().First().TriggerClick()); + AddStep("Reset to full area", () => settings.ChildrenOfType().First().TriggerClick()); ensureValid(); AddStep("rotate 10", () => tabletHandler.Rotation.Value = 10); @@ -129,7 +130,7 @@ public void TestOffsetValidity() private void ensureInvalid() => AddAssert("area invalid", () => !settings.AreaSelection.IsWithinBounds); - public class TestTabletHandler : ITabletHandler + public class TestTabletHandler : InputHandler, ITabletHandler { public Bindable AreaOffset { get; } = new Bindable(); public Bindable AreaSize { get; } = new Bindable(); @@ -149,7 +150,7 @@ public class TestTabletHandler : ITabletHandler private readonly Bindable tablet = new Bindable(); - public BindableBool Enabled { get; } = new BindableBool(true); + public override bool IsActive => true; public void SetTabletSize(Vector2 size) { diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterGroupingTest.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs rename to osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterGroupingTest.cs index 3e935ac5d75c..814e93014b80 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs +++ b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterGroupingTest.cs @@ -14,10 +14,9 @@ using osu.Game.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class BeatmapCarouselFilterGroupingTest diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterSortingTest.cs b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs similarity index 98% rename from osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterSortingTest.cs rename to osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs index 868abf958333..d6729d214111 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterSortingTest.cs +++ b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs @@ -12,10 +12,9 @@ using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class BeatmapCarouselFilterSortingTest diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselTestScene.cs similarity index 97% rename from osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs rename to osu.Game.Tests/Visual/SongSelect/BeatmapCarouselTestScene.cs index 02c017f57054..ae31606abb7c 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselTestScene.cs @@ -25,15 +25,13 @@ using osu.Game.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; using osuTK; using osuTK.Graphics; using osuTK.Input; -using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public abstract partial class BeatmapCarouselTestScene : OsuManualInputManagerTestScene { @@ -44,6 +42,8 @@ public abstract partial class BeatmapCarouselTestScene : OsuManualInputManagerTe protected TestBeatmapCarousel Carousel = null!; + protected bool RetainSelection { get; set; } + protected OsuScrollContainer Scroll => Carousel.ChildrenOfType>().Single(); [Cached(typeof(BeatmapStore))] @@ -78,7 +78,7 @@ private void load() Dependencies.Cache(Realm); } - protected void CreateCarousel() + protected void CreateCarousel(bool retainSelection = false) { AddStep("create components", () => { @@ -87,6 +87,8 @@ protected void CreateCarousel() BeatmapRecommendationFunction = null; NewItemsPresentedInvocationCount = 0; + GroupedBeatmap? previousSelection = retainSelection ? Carousel.CurrentGroupedBeatmap : null; + Box topBox; Children = new Drawable[] { @@ -120,6 +122,7 @@ protected void CreateCarousel() { Carousel = new TestBeatmapCarousel { + CurrentGroupedBeatmap = previousSelection, NewItemsPresented = _ => NewItemsPresentedInvocationCount++, RequestSelection = b => { @@ -222,6 +225,7 @@ protected void SelectPrevGroup() => AddStep("select prev group", () => protected void SelectPrevPanel() => AddStep("select prev panel", () => InputManager.Key(Key.Up)); protected void SelectNextSet() => AddStep("select next set", () => InputManager.Key(Key.Right)); protected void SelectPrevSet() => AddStep("select prev set", () => InputManager.Key(Key.Left)); + protected void SelectRandomSet() => AddStep("select random set", () => Carousel.NextRandom()); protected void Select() => AddStep("select", () => InputManager.Key(Key.Enter)); diff --git a/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs b/osu.Game.Tests/Visual/SongSelect/SongSelectComponentsTestScene.cs similarity index 97% rename from osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs rename to osu.Game.Tests/Visual/SongSelect/SongSelectComponentsTestScene.cs index 843d65b7f8e9..94ca56897ec4 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelect/SongSelectComponentsTestScene.cs @@ -9,7 +9,7 @@ using osu.Game.Graphics.Cursor; using osu.Game.Overlays; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public abstract partial class SongSelectComponentsTestScene : OsuManualInputManagerTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs b/osu.Game.Tests/Visual/SongSelect/SongSelectTestScene.cs similarity index 98% rename from osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs rename to osu.Game.Tests/Visual/SongSelect/SongSelectTestScene.cs index ac8591699a05..e5fce2981230 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelect/SongSelectTestScene.cs @@ -23,11 +23,11 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Menu; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public abstract partial class SongSelectTestScene : ScreenTestScene { @@ -38,7 +38,7 @@ public abstract partial class SongSelectTestScene : ScreenTestScene private RealmDetachedBeatmapStore beatmapStore = null!; - protected Screens.SelectV2.SongSelect SongSelect { get; private set; } = null!; + protected Screens.Select.SongSelect SongSelect { get; private set; } = null!; protected BeatmapCarousel Carousel => SongSelect.ChildrenOfType().Single(); [Cached] diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 11e754c8689f..85a5201b96e5 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -1,1467 +1,170 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; +using System.Threading.Tasks; using NUnit.Framework; -using osu.Framework.Allocation; -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.Configuration; -using osu.Game.Database; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Catch; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Taiko; using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; -using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; -using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { + /// + /// Covers common steps which can be used for manual testing. + /// [TestFixture] - public partial class TestSceneBeatmapCarousel : OsuManualInputManagerTestScene + public partial class TestSceneBeatmapCarousel : BeatmapCarouselTestScene { - private TestBeatmapCarousel carousel; - private RulesetStore rulesets; - - private readonly Stack selectedSets = new Stack(); - private readonly HashSet eagerSelectedIDs = new HashSet(); - - private BeatmapInfo currentSelection => carousel.SelectedBeatmapInfo; - - private const int set_count = 5; - private const int diff_count = 3; - - [Cached(typeof(BeatmapStore))] - private TestBeatmapStore beatmaps = new TestBeatmapStore(); - - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - this.rulesets = rulesets; - } - - [Test] - public void TestExternalRulesetChange() - { - createCarousel(new List()); - - AddStep("filter to ruleset 0", () => carousel.FilterImmediately(new FilterCriteria - { - Ruleset = rulesets.AvailableRulesets.ElementAt(0), - AllowConvertedBeatmaps = true, - })); - - AddStep("add mixed ruleset beatmapset", () => - { - var testMixed = TestResources.CreateTestBeatmapSetInfo(3); - - for (int i = 0; i <= 2; i++) - { - testMixed.Beatmaps[i].Ruleset = rulesets.AvailableRulesets.ElementAt(i); - } - - carousel.UpdateBeatmapSet(testMixed); - }); - - AddUntilStep("wait for filtered difficulties", () => - { - var visibleBeatmapPanels = carousel.Items.OfType().Where(p => p.IsPresent).ToArray(); - - return visibleBeatmapPanels.Length == 1 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item)!.BeatmapInfo.Ruleset.OnlineID == 0) == 1; - }); - - AddStep("filter to ruleset 1", () => carousel.FilterImmediately(new FilterCriteria - { - Ruleset = rulesets.AvailableRulesets.ElementAt(1), - AllowConvertedBeatmaps = true, - })); - - AddUntilStep("wait for filtered difficulties", () => - { - var visibleBeatmapPanels = carousel.Items.OfType().Where(p => p.IsPresent).ToArray(); - - return visibleBeatmapPanels.Length == 2 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item)!.BeatmapInfo.Ruleset.OnlineID == 0) == 1 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item)!.BeatmapInfo.Ruleset.OnlineID == 1) == 1; - }); - - AddStep("filter to ruleset 2", () => carousel.FilterImmediately(new FilterCriteria - { - Ruleset = rulesets.AvailableRulesets.ElementAt(2), - AllowConvertedBeatmaps = true, - })); - - AddUntilStep("wait for filtered difficulties", () => - { - var visibleBeatmapPanels = carousel.Items.OfType().Where(p => p.IsPresent).ToArray(); - - return visibleBeatmapPanels.Length == 2 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item!).BeatmapInfo.Ruleset.OnlineID == 0) == 1 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item!).BeatmapInfo.Ruleset.OnlineID == 2) == 1; - }); - } - - [Test] - public void TestScrollPositionMaintainedOnAdd() - { - loadBeatmaps(setCount: 1); - - for (int i = 0; i < 10; i++) - { - AddRepeatStep("Add some sets", () => carousel.UpdateBeatmapSet(TestResources.CreateTestBeatmapSetInfo()), 4); - - checkSelectionIsCentered(); - } - } - [Test] - public void TestDeletion() + [Explicit] + public void TestBasics() { - loadBeatmaps(setCount: 5, randomDifficulties: true); + CreateCarousel(); + RemoveAllBeatmaps(); - AddStep("remove first set", () => carousel.RemoveBeatmapSet(carousel.Items.Select(item => item.Item).OfType().First().BeatmapSet)); - AddUntilStep("4 beatmap sets visible", () => this.ChildrenOfType().Count(set => set.Alpha > 0) == 4); + AddBeatmaps(10, randomMetadata: true); + AddBeatmaps(10); + AddBeatmaps(1); } [Test] - public void TestScrollPositionMaintainedOnDelete() + [Explicit] + public void TestSorting() { - loadBeatmaps(setCount: 50); - - for (int i = 0; i < 10; i++) - { - AddRepeatStep("Remove some sets", () => - carousel.RemoveBeatmapSet(carousel.Items.Select(item => item.Item) - .OfType() - .OrderBy(item => item.GetHashCode()) - .First(item => item.State.Value != CarouselItemState.Selected && item.Visible).BeatmapSet), 4); - - checkSelectionIsCentered(); - } + SortAndGroupBy(SortMode.Artist, GroupMode.None); + SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty); + SortAndGroupBy(SortMode.Artist, GroupMode.Artist); } [Test] - public void TestManyPanels() + [Explicit] + public void TestRemovals() { - loadBeatmaps(setCount: 5000, randomDifficulties: true); + RemoveFirstBeatmap(); + RemoveAllBeatmaps(); } [Test] - public void TestKeyRepeat() + [Explicit] + public void TestLoadingDisplay() { - loadBeatmaps(); - advanceSelection(false); - - AddStep("press down arrow", () => InputManager.PressKey(Key.Down)); - - BeatmapInfo selection = null; - - checkSelectionIterating(true); - - AddStep("press up arrow", () => InputManager.PressKey(Key.Up)); - - checkSelectionIterating(true); - - AddStep("release down arrow", () => InputManager.ReleaseKey(Key.Down)); - - checkSelectionIterating(true); - - AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up)); - - checkSelectionIterating(false); - - void checkSelectionIterating(bool isIterating) - { - for (int i = 0; i < 3; i++) - { - AddStep("store selection", () => selection = carousel.SelectedBeatmapInfo); - if (isIterating) - AddUntilStep("selection changed", () => !carousel.SelectedBeatmapInfo?.Equals(selection) == true); - else - AddUntilStep("selection not changed", () => carousel.SelectedBeatmapInfo?.Equals(selection) == true); - } - } + AddStep("induce slow filtering", () => Carousel.FilterDelay = 2000); + SortAndGroupBy(SortMode.Artist, GroupMode.None); } [Test] - public void TestRecommendedSelection() + [Explicit] + public void TestAddRemoveRepeatedOps() { - loadBeatmaps(carouselAdjust: carousel => carousel.GetRecommendedBeatmap = beatmaps => beatmaps.LastOrDefault()); - - AddStep("select last", () => carousel.SelectBeatmap(carousel.BeatmapSets.Last().Beatmaps.Last())); - - // check recommended was selected - advanceSelection(direction: 1, diff: false); - waitForSelection(1, 3); - - // change away from recommended - advanceSelection(direction: -1, diff: true); - waitForSelection(1, 2); - - // next set, check recommended - advanceSelection(direction: 1, diff: false); - waitForSelection(2, 3); - - // next set, check recommended - advanceSelection(direction: 1, diff: false); - waitForSelection(3, 3); - - // go back to first set and ensure user selection was retained - advanceSelection(direction: -1, diff: false); - advanceSelection(direction: -1, diff: false); - waitForSelection(1, 2); + AddRepeatStep("add beatmaps", () => BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20); + AddRepeatStep("remove beatmaps", () => BeatmapSets.RemoveAt(RNG.Next(0, BeatmapSets.Count)), 20); } - /// - /// Test keyboard traversal - /// [Test] - public void TestTraversal() - { - loadBeatmaps(); - - AddStep("select first", () => carousel.SelectBeatmap(carousel.BeatmapSets.First().Beatmaps.First())); - waitForSelection(1, 1); - - advanceSelection(direction: 1, diff: true); - waitForSelection(1, 2); - - advanceSelection(direction: -1, diff: false); - waitForSelection(set_count, 1); - - advanceSelection(direction: -1, diff: true); - waitForSelection(set_count - 1, 3); - - advanceSelection(diff: false); - advanceSelection(diff: false); - waitForSelection(1, 2); - - advanceSelection(direction: -1, diff: true); - advanceSelection(direction: -1, diff: true); - waitForSelection(set_count, 3); - } - - [TestCase(true)] - [TestCase(false)] - public void TestTraversalBeyondVisible(bool forwards) + [Explicit] + public void TestMasking() { - var sets = new List(); - - const int total_set_count = 200; - - AddStep("Populuate beatmap sets", () => - { - sets.Clear(); - for (int i = 0; i < total_set_count; i++) - sets.Add(TestResources.CreateTestBeatmapSetInfo()); - }); - - loadBeatmaps(sets); - - for (int i = 1; i < total_set_count; i += i) - selectNextAndAssert(i); - - void selectNextAndAssert(int amount) - { - setSelected(forwards ? 1 : total_set_count, 1); - - AddStep($"{(forwards ? "Next" : "Previous")} beatmap {amount} times", () => - { - for (int i = 0; i < amount; i++) - { - carousel.SelectNext(forwards ? 1 : -1); - } - }); - - waitForSelection(forwards ? amount + 1 : total_set_count - amount); - } + AddStep("disable masking", () => Scroll.Masking = false); + AddStep("enable masking", () => Scroll.Masking = true); } [Test] - public void TestTraversalBeyondVisibleDifficulties() + [Explicit] + public void TestRandomStatus() { - var sets = new List(); - - const int total_set_count = 20; - - AddStep("Populuate beatmap sets", () => - { - sets.Clear(); - for (int i = 0; i < total_set_count; i++) - sets.Add(TestResources.CreateTestBeatmapSetInfo(3)); - }); - - loadBeatmaps(sets); - - // Selects next set once, difficulty index doesn't change - selectNextAndAssert(3, true, 2, 1); - - // Selects next set 16 times (50 \ 3 == 16), difficulty index changes twice (50 % 3 == 2) - selectNextAndAssert(50, true, 17, 3); - - // Travels around the carousel thrice (200 \ 60 == 3) - // continues to select 20 times (200 \ 60 == 20) - // selects next set 6 times (20 \ 3 == 6) - // difficulty index changes twice (20 % 3 == 2) - selectNextAndAssert(200, true, 7, 3); - - // All same but in reverse - selectNextAndAssert(3, false, 19, 3); - selectNextAndAssert(50, false, 4, 1); - selectNextAndAssert(200, false, 14, 1); - - void selectNextAndAssert(int amount, bool forwards, int expectedSet, int expectedDiff) + SortBy(SortMode.Title); + AddStep("add beatmaps", () => { - // Select very first or very last difficulty - setSelected(forwards ? 1 : 20, forwards ? 1 : 3); - - AddStep($"{(forwards ? "Next" : "Previous")} difficulty {amount} times", () => + for (int i = 0; i < 50; i++) { - for (int i = 0; i < amount; i++) - carousel.SelectNext(forwards ? 1 : -1, false); - }); - - waitForSelection(expectedSet, expectedDiff); - } - } - - /// - /// Test filtering - /// - [Test] - public void TestFiltering() - { - loadBeatmaps(); - - // basic filtering - setSelected(1, 1); - - AddStep("Filter", () => carousel.FilterImmediately(new FilterCriteria { SearchText = carousel.BeatmapSets.ElementAt(2).Metadata.Title })); - checkVisibleItemCount(diff: false, count: 1); - checkVisibleItemCount(diff: true, count: 3); - waitForSelection(3, 1); - - advanceSelection(diff: true, count: 4); - waitForSelection(3, 2); - - AddStep("Un-filter (debounce)", () => carousel.Filter(new FilterCriteria())); - AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); - checkVisibleItemCount(diff: false, count: set_count); - checkVisibleItemCount(diff: true, count: 3); - - // test filtering some difficulties (and keeping current beatmap set selected). - - setSelected(1, 2); - AddStep("Filter some difficulties", () => carousel.FilterImmediately(new FilterCriteria { SearchText = "Normal" })); - waitForSelection(1, 1); - - AddStep("Un-filter", () => carousel.FilterImmediately(new FilterCriteria())); - waitForSelection(1, 1); - - AddStep("Filter all", () => carousel.FilterImmediately(new FilterCriteria { SearchText = "Dingo" })); - - checkVisibleItemCount(false, 0); - checkVisibleItemCount(true, 0); - AddAssert("Selection is null", () => currentSelection == null); - - advanceSelection(true); - AddAssert("Selection is null", () => currentSelection == null); - - advanceSelection(false); - AddAssert("Selection is null", () => currentSelection == null); - - AddStep("Un-filter", () => carousel.FilterImmediately(new FilterCriteria())); - - AddAssert("Selection is non-null", () => currentSelection != null); - - setSelected(1, 3); - } - - [Test] - public void TestFilterRange() - { - string searchText = null; - - loadBeatmaps(); - - // buffer the selection - setSelected(3, 2); - - AddStep("get search text", () => searchText = carousel.SelectedBeatmapSet!.Metadata.Title); + var set = TestResources.CreateTestBeatmapSetInfo(); + set.Status = Enum.GetValues().MinBy(_ => RNG.Next()); - setSelected(1, 3); + if (i % 2 == 0) + set.Status = BeatmapOnlineStatus.None; - AddStep("Apply a range filter", () => carousel.FilterImmediately(new FilterCriteria - { - SearchText = searchText, - StarDifficulty = new FilterCriteria.OptionalRange - { - Min = 2, - Max = 5.5, - IsLowerInclusive = true + BeatmapSets.Add(set); } - })); - - // should reselect the buffered selection. - waitForSelection(3, 2); - } - - /// - /// Test random non-repeating algorithm - /// - [Test] - public void TestRandom() - { - loadBeatmaps(); - - setSelected(1, 1); - - nextRandom(); - ensureRandomDidntRepeat(); - nextRandom(); - ensureRandomDidntRepeat(); - nextRandom(); - ensureRandomDidntRepeat(); - - prevRandom(); - ensureRandomFetchSuccess(); - prevRandom(); - ensureRandomFetchSuccess(); - - nextRandom(); - ensureRandomDidntRepeat(); - nextRandom(); - ensureRandomDidntRepeat(); - - nextRandom(); - AddAssert("ensure repeat", () => selectedSets.Contains(carousel.SelectedBeatmapSet)); - - AddStep("Add set with 100 difficulties", () => carousel.UpdateBeatmapSet(TestResources.CreateTestBeatmapSetInfo(100, rulesets.AvailableRulesets.ToArray()))); - AddStep("Filter Extra", () => carousel.FilterImmediately(new FilterCriteria { SearchText = "Extra 10" })); - checkInvisibleDifficultiesUnselectable(); - checkInvisibleDifficultiesUnselectable(); - checkInvisibleDifficultiesUnselectable(); - checkInvisibleDifficultiesUnselectable(); - checkInvisibleDifficultiesUnselectable(); - AddStep("Un-filter", () => carousel.FilterImmediately(new FilterCriteria())); - } - - [Test] - public void TestRewind() - { - const int local_set_count = 3; - const int random_select_count = local_set_count * 3; - loadBeatmaps(setCount: local_set_count); - - for (int i = 0; i < random_select_count; i++) - nextRandom(); - - for (int i = 0; i < random_select_count; i++) - { - prevRandom(); - AddAssert("correct random last selected", () => selectedSets.Peek(), () => Is.EqualTo(carousel.SelectedBeatmapSet)); - } - } - - [Test] - public void TestRewindToDeletedBeatmap() - { - loadBeatmaps(); - - var firstAdded = TestResources.CreateTestBeatmapSetInfo(); - - AddStep("add new set", () => carousel.UpdateBeatmapSet(firstAdded)); - AddStep("select set", () => carousel.SelectBeatmap(firstAdded.Beatmaps.First())); - - nextRandom(); - - AddStep("delete set", () => carousel.RemoveBeatmapSet(firstAdded)); - - prevRandom(); - - AddAssert("deleted set not selected", () => carousel.SelectedBeatmapSet?.Equals(firstAdded) == false); - } - - /// - /// Test adding and removing beatmap sets - /// - [Test] - public void TestAddRemove() - { - loadBeatmaps(); - - var firstAdded = TestResources.CreateTestBeatmapSetInfo(); - var secondAdded = TestResources.CreateTestBeatmapSetInfo(); - - AddStep("Add new set", () => carousel.UpdateBeatmapSet(firstAdded)); - AddStep("Add new set", () => carousel.UpdateBeatmapSet(secondAdded)); - - checkVisibleItemCount(false, set_count + 2); - - AddStep("Remove set", () => carousel.RemoveBeatmapSet(firstAdded)); - - checkVisibleItemCount(false, set_count + 1); - - setSelected(set_count + 1, 1); - - AddStep("Remove set", () => carousel.RemoveBeatmapSet(secondAdded)); - - checkVisibleItemCount(false, set_count); - - waitForSelection(set_count); - } - - [Test] - public void TestDifficultiesSplitOutOnLoad() - { - loadBeatmaps(new List { TestResources.CreateTestBeatmapSetInfo(diff_count) }, () => new FilterCriteria - { - Sort = SortMode.Difficulty, - }); - - checkVisibleItemCount(false, 3); - } - - [Test] - public void TestAddRemoveDifficultySort() - { - const int local_set_count = 2; - const int local_diff_count = 2; - - loadBeatmaps(setCount: local_set_count, diffCount: local_diff_count); - - AddStep("Sort by difficulty", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty })); - - checkVisibleItemCount(false, local_set_count * local_diff_count); - - var firstAdded = TestResources.CreateTestBeatmapSetInfo(local_diff_count); - firstAdded.Status = BeatmapOnlineStatus.Loved; - - AddStep("Add new set", () => carousel.UpdateBeatmapSet(firstAdded)); - - checkVisibleItemCount(false, (local_set_count + 1) * local_diff_count); - - AddStep("Remove set", () => carousel.RemoveBeatmapSet(firstAdded)); - - checkVisibleItemCount(false, (local_set_count) * local_diff_count); - - setSelected(local_set_count, 1); - - waitForSelection(local_set_count); - } - - [Test] - public void TestSelectionEnteringFromEmptyRuleset() - { - var sets = new List(); - - AddStep("Create beatmaps for taiko only", () => - { - sets.Clear(); - - var rulesetBeatmapSet = TestResources.CreateTestBeatmapSetInfo(1); - var taikoRuleset = rulesets.AvailableRulesets.ElementAt(1); - rulesetBeatmapSet.Beatmaps.ForEach(b => b.Ruleset = taikoRuleset); - - sets.Add(rulesetBeatmapSet); }); - - loadBeatmaps(sets, () => new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }); - - AddStep("Set non-empty mode filter", () => - carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1) })); - - AddAssert("Something is selected", () => carousel.SelectedBeatmapInfo != null); } [Test] - public void TestSortingDateSubmitted() + public void TestClickExpiredPanel() { - var sets = new List(); - const string zzz_string = "zzzzz"; + CreateCarousel(); - AddStep("Populuate beatmap sets", () => + AddStep("set eager loading very low", () => { - sets.Clear(); - - for (int i = 0; i < 10; i++) - { - var set = TestResources.CreateTestBeatmapSetInfo(5); - - // A total of 6 sets have date submitted (4 don't) - // A total of 5 sets have artist string (3 of which also have date submitted) - - if (i >= 2 && i < 8) // i = 2, 3, 4, 5, 6, 7 have submitted date - set.DateSubmitted = DateTimeOffset.Now.AddMinutes(i); - if (i < 5) // i = 0, 1, 2, 3, 4 have matching string - set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string); - - set.Beatmaps.ForEach(b => b.Metadata.Title = $"submitted: {set.DateSubmitted}"); - - sets.Add(set); - } + Carousel.DistanceOffscreenToPreload = -100; }); - loadBeatmaps(sets); + AddBeatmaps(50, 10); - AddStep("Sort by date submitted", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.DateSubmitted })); - checkVisibleItemCount(diff: false, count: 10); - checkVisibleItemCount(diff: true, count: 5); + AddUntilStep("wait for panels", () => Carousel.ChildrenOfType().Any()); - AddAssert("missing date are at end", - () => carousel.Items.OfType().Reverse().TakeWhile(i => i.Item is CarouselBeatmapSet s && s.BeatmapSet.DateSubmitted == null).Count(), () => Is.EqualTo(4)); - AddAssert("rest are at start", () => carousel.Items.OfType().TakeWhile(i => i.Item is CarouselBeatmapSet s && s.BeatmapSet.DateSubmitted != null).Count(), - () => Is.EqualTo(6)); - - AddStep("Sort by date submitted and string", () => carousel.FilterImmediately(new FilterCriteria - { - Sort = SortMode.DateSubmitted, - SearchText = zzz_string - })); - checkVisibleItemCount(diff: false, count: 5); - checkVisibleItemCount(diff: true, count: 5); - - AddAssert("missing date are at end", - () => carousel.Items.OfType().Reverse().TakeWhile(i => i.Item is CarouselBeatmapSet s && s.BeatmapSet.DateSubmitted == null).Count(), () => Is.EqualTo(2)); - AddAssert("rest are at start", () => carousel.Items.OfType().TakeWhile(i => i.Item is CarouselBeatmapSet s && s.BeatmapSet.DateSubmitted != null).Count(), - () => Is.EqualTo(3)); + AddRepeatStep("click last panel", () => Carousel.ChildrenOfType().FirstOrDefault()?.TriggerClick(), 20); } [Test] - public void TestSorting() + public void TestHighChurnUpdatesStillShowsPanels() { - var sets = new List(); + ScheduledDelegate updateTask = null!; - const string zzz_lowercase = "zzzzz"; - const string zzz_uppercase = "ZZZZZ"; + AddBeatmaps(1, 1); - AddStep("Populuate beatmap sets", () => + AddStep("start constantly updating beatmap in background", () => { - sets.Clear(); - - for (int i = 0; i < 20; i++) - { - var set = TestResources.CreateTestBeatmapSetInfo(); - - if (i == 4) - set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_uppercase); - - if (i == 8) - set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_lowercase); - - if (i == 12) - set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_uppercase); - - if (i == 16) - set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_lowercase); - - sets.Add(set); - } + updateTask = Scheduler.AddDelayed(() => { BeatmapSets.ReplaceRange(0, 1, [BeatmapSets.First()]); }, 1, true); }); - loadBeatmaps(sets); - - AddStep("Sort by author", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Author })); - AddAssert($"Check {zzz_uppercase} is last", () => carousel.BeatmapSets.Last().Metadata.Author.Username == zzz_uppercase); - AddAssert($"Check {zzz_lowercase} is second last", () => carousel.BeatmapSets.SkipLast(1).Last().Metadata.Author.Username == zzz_lowercase); - AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); - AddAssert($"Check {zzz_uppercase} is last", () => carousel.BeatmapSets.Last().Metadata.Artist == zzz_uppercase); - AddAssert($"Check {zzz_lowercase} is second last", () => carousel.BeatmapSets.SkipLast(1).Last().Metadata.Artist == zzz_lowercase); - } - - [Test] - public void TestSortByArtistUsesTitleAsTiebreaker() - { - var sets = new List(); - - AddStep("Populuate beatmap sets", () => - { - sets.Clear(); - - for (int i = 0; i < 20; i++) - { - var set = TestResources.CreateTestBeatmapSetInfo(); - - if (i == 4) - { - set.Beatmaps.ForEach(b => - { - b.Metadata.Artist = "ZZZ"; - b.Metadata.Title = "AAA"; - }); - } + CreateCarousel(); - if (i == 8) - { - set.Beatmaps.ForEach(b => - { - b.Metadata.Artist = "ZZZ"; - b.Metadata.Title = "ZZZ"; - }); - } + AddUntilStep("panels loaded", () => Carousel.ChildrenOfType(), () => Is.Not.Empty); - sets.Add(set); - } - }); - - loadBeatmaps(sets); - - AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); - AddAssert("Check last item", () => - { - var lastItem = carousel.BeatmapSets.Last(); - return lastItem.Metadata.Artist == "ZZZ" && lastItem.Metadata.Title == "ZZZ"; - }); - AddAssert("Check second last item", () => - { - var secondLastItem = carousel.BeatmapSets.SkipLast(1).Last(); - return secondLastItem.Metadata.Artist == "ZZZ" && secondLastItem.Metadata.Title == "AAA"; - }); + AddStep("end task", () => updateTask.Cancel()); } - /// - /// Ensures stability is maintained on different sort modes for items with equal properties. - /// [Test] - public void TestSortingStabilityDateAdded() + [Explicit] + public void TestPerformanceWithManyBeatmaps() { - var sets = new List(); + const int count = 200000; - AddStep("Populuate beatmap sets", () => - { - sets.Clear(); + List generated = new List(); - for (int i = 0; i < 10; i++) + AddStep($"populate {count} test beatmaps", () => + { + generated.Clear(); + Task.Run(() => { - var set = TestResources.CreateTestBeatmapSetInfo(); - - set.DateAdded = DateTimeOffset.FromUnixTimeSeconds(i); - - // only need to set the first as they are a shared reference. - var beatmap = set.Beatmaps.First(); - - beatmap.Metadata.Artist = "a"; - beatmap.Metadata.Title = "b"; - - sets.Add(set); - } + for (int j = 0; j < count; j++) + generated.Add(CreateTestBeatmapSetInfo(3, true)); + }).ConfigureAwait(true); }); - loadBeatmaps(sets); + AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3)); + AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2)); + AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count)); - AddStep("Sort by title", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title })); - AddAssert("Items remain in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); - - AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); - AddAssert("Items remain in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); + AddStep("add all beatmaps", () => BeatmapSets.AddRange(generated)); } - /// - /// Ensures stability is maintained on different sort modes while a new item is added to the carousel. - /// [Test] - public void TestSortingStabilityWithRemovedAndReaddedItem() + public void TestSingleItemDisplayed() { - List sets = new List(); - - AddStep("Populuate beatmap sets", () => - { - sets.Clear(); - - for (int i = 0; i < 3; i++) - { - var set = TestResources.CreateTestBeatmapSetInfo(diff_count); - - // only need to set the first as they are a shared reference. - var beatmap = set.Beatmaps.First(); - - beatmap.Metadata.Artist = "same artist"; - beatmap.Metadata.Title = "same title"; - - // testing the case where DateAdded happens to equal (quite rare). - set.DateAdded = DateTimeOffset.UnixEpoch; + CreateCarousel(); + RemoveAllBeatmaps(); - sets.Add(set); - } - }); - - Guid[] originalOrder = null!; - - loadBeatmaps(sets); - - AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); - - AddAssert("Items in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); - AddStep("Save order", () => originalOrder = carousel.BeatmapSets.Select(s => s.ID).ToArray()); - - AddStep("Remove item", () => carousel.RemoveBeatmapSet(sets[1])); - AddStep("Re-add item", () => carousel.UpdateBeatmapSet(sets[1])); - - AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); - - AddStep("Sort by title", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title })); - AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); - } - - /// - /// Ensures stability is maintained on different sort modes while a new item is added to the carousel. - /// - [Test] - public void TestSortingStabilityWithNewItems() - { - List sets = new List(); - - AddStep("Populuate beatmap sets", () => - { - sets.Clear(); - - for (int i = 0; i < 3; i++) - { - var set = TestResources.CreateTestBeatmapSetInfo(diff_count); - - // only need to set the first as they are a shared reference. - var beatmap = set.Beatmaps.First(); - - beatmap.Metadata.Artist = "same artist"; - beatmap.Metadata.Title = "same title"; - - // testing the case where DateAdded happens to equal (quite rare). - set.DateAdded = DateTimeOffset.UnixEpoch; - - sets.Add(set); - } - }); - - Guid[] originalOrder = null!; - - loadBeatmaps(sets); - - AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); - - AddAssert("Items in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); - AddStep("Save order", () => originalOrder = carousel.BeatmapSets.Select(s => s.ID).ToArray()); - - AddStep("Add new item", () => - { - var set = TestResources.CreateTestBeatmapSetInfo(); - - // only need to set the first as they are a shared reference. - var beatmap = set.Beatmaps.First(); - - beatmap.Metadata.Artist = "same artist"; - beatmap.Metadata.Title = "same title"; - - set.DateAdded = DateTimeOffset.FromUnixTimeSeconds(1); - - carousel.UpdateBeatmapSet(set); - - // add set to expected ordering - originalOrder = originalOrder.Prepend(set.ID).ToArray(); - }); - - AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); - - AddStep("Sort by title", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title })); - AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); - } - - [Test] - public void TestSortingWithDifficultyFiltered() - { - const int local_diff_count = 3; - const int local_set_count = 2; - - List sets = new List(); - - AddStep("Populuate beatmap sets", () => - { - sets.Clear(); - - for (int i = 0; i < local_set_count; i++) - { - var set = TestResources.CreateTestBeatmapSetInfo(local_diff_count); - set.Beatmaps[0].StarRating = 3 - i; - set.Beatmaps[1].StarRating = 6 + i; - sets.Add(set); - } - }); - - loadBeatmaps(sets); - - AddStep("Sort by difficulty", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty })); - - checkVisibleItemCount(false, local_set_count * local_diff_count); - checkVisibleItemCount(true, 1); - - AddStep("Filter to normal", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Normal" })); - checkVisibleItemCount(false, local_set_count); - checkVisibleItemCount(true, 1); - - AddUntilStep("Check all visible sets have one normal", () => - { - return carousel.Items.OfType() - .Where(p => p.IsPresent) - .Count(p => ((CarouselBeatmapSet)p.Item)!.Beatmaps.Single().BeatmapInfo.DifficultyName.StartsWith("Normal", StringComparison.Ordinal)) == local_set_count; - }); - - AddStep("Filter to insane", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Insane" })); - checkVisibleItemCount(false, local_set_count); - checkVisibleItemCount(true, 1); - - AddUntilStep("Check all visible sets have one insane", () => - { - return carousel.Items.OfType() - .Where(p => p.IsPresent) - .Count(p => ((CarouselBeatmapSet)p.Item)!.Beatmaps.Single().BeatmapInfo.DifficultyName.StartsWith("Insane", StringComparison.Ordinal)) == local_set_count; - }); - } - - [Test] - public void TestRemoveAll() - { - loadBeatmaps(); - - setSelected(2, 1); - AddAssert("Selection is non-null", () => currentSelection != null); - - AddStep("Remove selected", () => carousel.RemoveBeatmapSet(carousel.SelectedBeatmapSet!)); - waitForSelection(2); - - AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); - AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); - waitForSelection(1); - - AddUntilStep("Remove all", () => - { - if (!carousel.BeatmapSets.Any()) return true; - - carousel.RemoveBeatmapSet(carousel.BeatmapSets.Last()); - return false; - }); - - checkNoSelection(); - } - - [Test] - public void TestEmptyTraversal() - { - loadBeatmaps(new List()); - - advanceSelection(direction: 1, diff: false); - checkNoSelection(); - - advanceSelection(direction: 1, diff: true); - checkNoSelection(); - - advanceSelection(direction: -1, diff: false); - checkNoSelection(); - - advanceSelection(direction: -1, diff: true); - checkNoSelection(); - } - - [Test] - public void TestHiding() - { - BeatmapSetInfo hidingSet = null; - List hiddenList = new List(); - - AddStep("create hidden set", () => - { - hidingSet = TestResources.CreateTestBeatmapSetInfo(diff_count); - hidingSet.Beatmaps[1].Hidden = true; - - hiddenList.Clear(); - hiddenList.Add(hidingSet); - }); - - loadBeatmaps(hiddenList); - - setSelected(1, 1); - - checkVisibleItemCount(true, 2); - advanceSelection(true); - waitForSelection(1, 3); - - setHidden(3); - waitForSelection(1, 1); - - setHidden(2, false); - advanceSelection(true); - waitForSelection(1, 2); - - setHidden(1); - waitForSelection(1, 2); - - setHidden(2); - checkNoSelection(); - - void setHidden(int diff, bool hidden = true) - { - AddStep((hidden ? "" : "un") + $"hide diff {diff}", () => - { - hidingSet.Beatmaps[diff - 1].Hidden = hidden; - carousel.UpdateBeatmapSet(hidingSet); - }); - } - } - - [Test] - public void TestSelectingFilteredRuleset() - { - BeatmapSetInfo testMixed = null; - - createCarousel(new List()); - - AddStep("add mixed ruleset beatmapset", () => - { - testMixed = TestResources.CreateTestBeatmapSetInfo(diff_count); - - for (int i = 0; i <= 2; i++) - { - testMixed.Beatmaps[i].Ruleset = rulesets.AvailableRulesets.ElementAt(i); - } - - carousel.UpdateBeatmapSet(testMixed); - }); - AddStep("filter to ruleset 0", () => - carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) })); - AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testMixed.Beatmaps[1], false)); - AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 0); - - AddStep("remove mixed set", () => - { - carousel.RemoveBeatmapSet(testMixed); - testMixed = null; - }); - BeatmapSetInfo testSingle = null; - AddStep("add single ruleset beatmapset", () => - { - testSingle = TestResources.CreateTestBeatmapSetInfo(diff_count); - testSingle.Beatmaps.ForEach(b => - { - b.Ruleset = rulesets.AvailableRulesets.ElementAt(1); - }); - - carousel.UpdateBeatmapSet(testSingle); - }); - AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testSingle.Beatmaps[0], false)); - checkNoSelection(); - AddStep("remove single ruleset set", () => carousel.RemoveBeatmapSet(testSingle)); - } - - [Test] - public void TestCarouselRemembersSelection() - { - List manySets = new List(); - - AddStep("Populuate beatmap sets", () => - { - manySets.Clear(); - - for (int i = 1; i <= 50; i++) - manySets.Add(TestResources.CreateTestBeatmapSetInfo(diff_count)); - }); - - loadBeatmaps(manySets); - - advanceSelection(direction: 1, diff: false); - - for (int i = 0; i < 5; i++) - { - AddStep("Toggle non-matching filter", () => - { - carousel.FilterImmediately(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }); - }); - - AddStep("Restore no filter", () => - { - carousel.FilterImmediately(new FilterCriteria()); - eagerSelectedIDs.Add(carousel.SelectedBeatmapSet!.ID); - }); - } - - // always returns to same selection as long as it's available. - AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); - } - - [Test] - public void TestCarouselRemembersSelectionDifficultySort() - { - List manySets = new List(); - - AddStep("Populate beatmap sets", () => - { - manySets.Clear(); - - for (int i = 1; i <= 50; i++) - manySets.Add(TestResources.CreateTestBeatmapSetInfo(diff_count)); - }); - - loadBeatmaps(manySets); - - AddStep("Sort by difficulty", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty })); - - advanceSelection(direction: 1, diff: false); - - for (int i = 0; i < 5; i++) - { - AddStep("Toggle non-matching filter", () => - { - carousel.FilterImmediately(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }); - }); - - AddStep("Restore no filter", () => - { - carousel.FilterImmediately(new FilterCriteria()); - eagerSelectedIDs.Add(carousel.SelectedBeatmapSet!.ID); - }); - } - - // always returns to same selection as long as it's available. - AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); - } - - [Test] - public void TestCarouselRetainsSelectionFromDifficultySort() - { - List manySets = new List(); - - AddStep("Populate beatmap sets", () => - { - manySets.Clear(); - - for (int i = 1; i <= 50; i++) - manySets.Add(TestResources.CreateTestBeatmapSetInfo(diff_count)); - }); - - loadBeatmaps(manySets); - - BeatmapInfo chosenBeatmap = null!; - AddStep("select given beatmap", () => carousel.SelectBeatmap(chosenBeatmap = manySets[20].Beatmaps[0])); - AddUntilStep("selection changed", () => carousel.SelectedBeatmapInfo, () => Is.EqualTo(chosenBeatmap)); - - AddStep("sort by difficulty", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty })); - AddAssert("selection retained", () => carousel.SelectedBeatmapInfo, () => Is.EqualTo(chosenBeatmap)); - - AddStep("sort by title", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title })); - AddAssert("selection retained", () => carousel.SelectedBeatmapInfo, () => Is.EqualTo(chosenBeatmap)); - } - - [Test] - public void TestFilteringByUserStarDifficulty() - { - BeatmapSetInfo set = null; - - loadBeatmaps(new List()); - - AddStep("add mixed difficulty set", () => - { - set = TestResources.CreateTestBeatmapSetInfo(1); - set.Beatmaps.Clear(); - - for (int i = 1; i <= 15; i++) - { - set.Beatmaps.Add(new BeatmapInfo(new OsuRuleset().RulesetInfo, new BeatmapDifficulty(), new BeatmapMetadata()) - { - DifficultyName = $"Stars: {i}", - StarRating = i, - }); - } - - carousel.UpdateBeatmapSet(set); - }); - - AddStep("select added set", () => carousel.SelectBeatmap(set.Beatmaps[0], false)); - - AddStep("filter [5..]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 5 } })); - AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); - checkVisibleItemCount(true, 11); - - AddStep("filter to [0..7]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Max = 7 } })); - AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); - checkVisibleItemCount(true, 7); - - AddStep("filter to [5..7]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 5, Max = 7 } })); - AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); - checkVisibleItemCount(true, 3); - - AddStep("filter [2..2]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 2, Max = 2 } })); - AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); - checkVisibleItemCount(true, 1); - - AddStep("filter to [0..]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 0 } })); - AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); - checkVisibleItemCount(true, 15); - } - - [Test] - public void TestCarouselSelectsNextWhenPreviousIsFiltered() - { - List sets = new List(); - - // 10 sets that go osu! -> taiko -> catch -> osu! -> ... - for (int i = 0; i < 10; i++) - sets.Add(TestResources.CreateTestBeatmapSetInfo(5, new[] { getRuleset(i) })); - - // Sort mode is important to keep the ruleset order - loadBeatmaps(sets, () => new FilterCriteria { Sort = SortMode.Title }); - setSelected(1, 1); - - for (int i = 1; i < 10; i++) - { - var rulesetInfo = getRuleset(i % 3); - - AddStep($"Set ruleset to {rulesetInfo.ShortName}", () => - { - carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesetInfo, Sort = SortMode.Title }); - }); - waitForSelection(i + 1, 1); - } - - static RulesetInfo getRuleset(int index) - { - switch (index % 3) - { - default: - return new OsuRuleset().RulesetInfo; - - case 1: - return new TaikoRuleset().RulesetInfo; - - case 2: - return new CatchRuleset().RulesetInfo; - } - } - } - - [Test] - public void TestCarouselSelectsBackwardsWhenDistanceIsShorter() - { - List sets = new List(); - - // 10 sets that go taiko, osu!, osu!, osu!, taiko, osu!, osu!, osu!, ... - for (int i = 0; i < 10; i++) - sets.Add(TestResources.CreateTestBeatmapSetInfo(5, new[] { getRuleset(i) })); - - // Sort mode is important to keep the ruleset order - loadBeatmaps(sets, () => new FilterCriteria { Sort = SortMode.Title }); - - for (int i = 2; i < 10; i += 4) - { - setSelected(i, 1); - AddStep("Set ruleset to taiko", () => - { - carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1), Sort = SortMode.Title }); - }); - waitForSelection(i - 1, 1); - AddStep("Remove ruleset filter", () => - { - carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title }); - }); - } - - static RulesetInfo getRuleset(int index) - { - switch (index % 4) - { - case 0: - return new TaikoRuleset().RulesetInfo; - - default: - return new OsuRuleset().RulesetInfo; - } - } - } - - private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null, - int? setCount = null, int? diffCount = null, bool randomDifficulties = false) - { - bool changed = false; - - if (beatmapSets == null) - { - beatmapSets = new List(); - var statuses = Enum.GetValues() - .Except(new[] { BeatmapOnlineStatus.None }) // make sure a badge is always shown. - .ToArray(); - - for (int i = 1; i <= (setCount ?? set_count); i++) - { - var set = randomDifficulties - ? TestResources.CreateTestBeatmapSetInfo() - : TestResources.CreateTestBeatmapSetInfo(diffCount ?? diff_count); - set.Status = statuses[RNG.Next(statuses.Length)]; - - beatmapSets.Add(set); - } - } - - createCarousel(beatmapSets, initialCriteria, c => - { - carousel.BeatmapSetsChanged = () => changed = true; - carouselAdjust?.Invoke(c); - }); - - AddUntilStep("Wait for load", () => changed); - } - - private void createCarousel(List beatmapSets, [CanBeNull] Func initialCriteria = null, Action carouselAdjust = null, Container target = null) - { - AddStep("Create carousel", () => - { - selectedSets.Clear(); - eagerSelectedIDs.Clear(); - - carousel = new TestBeatmapCarousel(initialCriteria?.Invoke() ?? new FilterCriteria()) - { - RelativeSizeAxes = Axes.Both, - }; - - carouselAdjust?.Invoke(carousel); - - beatmaps.BeatmapSets.Clear(); - beatmaps.BeatmapSets.AddRange(beatmapSets); - - (target ?? this).Child = carousel; - }); - } - - private void ensureRandomFetchSuccess() => - AddAssert("ensure prev random fetch worked", () => selectedSets.Peek().Equals(carousel.SelectedBeatmapSet)); - - private void waitForSelection(int set, int? diff = null) => - AddUntilStep($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () => - { - if (diff != null) - return carousel.SelectedBeatmapInfo?.Equals(carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Skip(diff.Value - 1).First()) == true; - - return carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Contains(carousel.SelectedBeatmapInfo); - }); - - private void setSelected(int set, int diff) => - AddStep($"select set{set} diff{diff}", () => - carousel.SelectBeatmap(carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Skip(diff - 1).First())); - - private void advanceSelection(bool diff, int direction = 1, int count = 1) - { - if (count == 1) - { - AddStep($"select {(direction > 0 ? "next" : "prev")} {(diff ? "diff" : "set")}", () => - carousel.SelectNext(direction, !diff)); - } - else - { - AddRepeatStep($"select {(direction > 0 ? "next" : "prev")} {(diff ? "diff" : "set")}", () => - carousel.SelectNext(direction, !diff), count); - } - } - - private void checkVisibleItemCount(bool diff, int count) - { - // until step required as we are querying against alive items, which are loaded asynchronously inside DrawableCarouselBeatmapSet. - AddUntilStep($"{count} {(diff ? "diffs" : "sets")} visible", () => - carousel.Items.Count(s => (diff ? s.Item is CarouselBeatmap : s.Item is CarouselBeatmapSet) && s.Item.Visible), () => Is.EqualTo(count)); - } - - private void checkSelectionIsCentered() - { - AddAssert("Selected panel is centered", () => - { - return Precision.AlmostEquals( - carousel.ScreenSpaceDrawQuad.Centre, - carousel.Items - .First(i => i.Item?.State.Value == CarouselItemState.Selected) - .ScreenSpaceDrawQuad.Centre, 100); - }); - } - - private void checkNoSelection() => AddAssert("Selection is null", () => currentSelection == null); - - private void nextRandom() => - AddStep("select random next", () => - { - carousel.RandomAlgorithm.Value = RandomSelectAlgorithm.RandomPermutation; - - if (!selectedSets.Any() && carousel.SelectedBeatmapInfo != null) - selectedSets.Push(carousel.SelectedBeatmapSet); - - carousel.SelectNextRandom(); - selectedSets.Push(carousel.SelectedBeatmapSet); - }); - - private void ensureRandomDidntRepeat() => - AddAssert("ensure no repeats", () => selectedSets.Distinct().Count() == selectedSets.Count); - - private void prevRandom() => AddStep("select random last", () => - { - carousel.SelectPreviousRandom(); - selectedSets.Pop(); - }); - - private bool selectedBeatmapVisible() - { - var currentlySelected = carousel.Items.FirstOrDefault(s => s.Item is CarouselBeatmap && s.Item.State.Value == CarouselItemState.Selected); - if (currentlySelected == null) - return true; - - return currentlySelected.Item!.Visible; - } - - private void checkInvisibleDifficultiesUnselectable() - { - nextRandom(); - AddAssert("Selection is visible", selectedBeatmapVisible); - } - - private partial class TestBeatmapCarousel : BeatmapCarousel - { - public TestBeatmapCarousel(FilterCriteria criteria) - : base(criteria) - { - } - - public bool PendingFilterTask => PendingFilter != null; - - public IEnumerable Items - { - get - { - foreach (var item in Scroll.Children.OrderBy(c => c.Y)) - { - if (item.Item?.Visible != true) - continue; - - yield return item; - - if (item is DrawableCarouselBeatmapSet set) - { - foreach (var difficulty in set.DrawableBeatmaps) - yield return difficulty; - } - } - } - } - - public void FilterImmediately(FilterCriteria newCriteria) - { - Filter(newCriteria); - FlushPendingFilterOperations(); - } + SortAndGroupBy(SortMode.Difficulty, GroupMode.None); + AddBeatmaps(1, fixedDifficultiesPerSet: 1); + AddUntilStep("single item is shown", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); } } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselArtistGrouping.cs similarity index 84% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselArtistGrouping.cs index 2390261cdbc6..a591dff01060 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselArtistGrouping.cs @@ -7,12 +7,12 @@ using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.Carousel; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; using osuTK; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class TestSceneBeatmapCarouselArtistGrouping : BeatmapCarouselTestScene @@ -253,6 +253,54 @@ public void TestBasicFiltering() CheckDisplayedBeatmapsCount(30); } + [Test] + public void TestGroupDoesExpandAfterRandomTraversal() + { + SelectNextSet(); + + ToggleGroupCollapse(); + AddAssert("group not expanded", () => Carousel.ExpandedGroup, () => Is.Null); + + SelectRandomSet(); + + AddAssert("group expanded", () => Carousel.ExpandedGroup, () => Is.Not.Null); + } + + [Test] + public void TestFilterWhileCollapsedUpdatesVisualStateCorrectly() + { + SelectNextSet(); + + CheckHasSelection(); + AddAssert("group expanded", () => Carousel.ExpandedGroup, () => Is.Not.Null); + AddAssert("has expanded set", () => Carousel.ExpandedBeatmapSet != null); + + AddAssert("has visible beatmaps", () => Carousel.GetCarouselItems()!.Count(item => item.Model is GroupedBeatmap && item.IsVisible), () => Is.EqualTo(3)); + AddAssert("has visually expanded set", () => Carousel.GetCarouselItems()!.Count(item => item.Model is GroupedBeatmapSet && item.IsExpanded && item.IsVisible), () => Is.EqualTo(1)); + + ToggleGroupCollapse(); + + CheckHasSelection(); + AddAssert("group not expanded", () => Carousel.ExpandedGroup, () => Is.Null); + AddAssert("has expanded set", () => Carousel.ExpandedBeatmapSet != null); + + AddAssert("has no visible beatmaps", () => Carousel.GetCarouselItems()!.Count(item => item.Model is GroupedBeatmap && item.IsVisible), () => Is.Zero); + AddAssert("has no visually expanded set", () => Carousel.GetCarouselItems()!.Count(item => item.Model is GroupedBeatmapSet && item.IsExpanded && item.IsVisible), () => Is.Zero); + + // filter while collapsed. + ApplyToFilterAndWaitForFilter("filter", c => c.SearchText = Carousel.SelectedBeatmapSet!.Metadata.Title); + + // then expand. + ToggleGroupCollapse(); + + CheckHasSelection(); + AddAssert("group expanded", () => Carousel.ExpandedGroup, () => Is.Not.Null); + AddAssert("has expanded set", () => Carousel.ExpandedBeatmapSet != null); + + AddAssert("has visible beatmaps", () => Carousel.GetCarouselItems()!.Count(item => item.Model is GroupedBeatmap && item.IsVisible), () => Is.EqualTo(3)); + AddAssert("has visually expanded set", () => Carousel.GetCarouselItems()!.Count(item => item.Model is GroupedBeatmapSet && item.IsExpanded && item.IsVisible), () => Is.EqualTo(1)); + } + [Test] public void TestGroupDoesNotExpandAgainOnRefilterIfManuallyCollapsed() { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselCollectionGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselCollectionGrouping.cs similarity index 98% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselCollectionGrouping.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselCollectionGrouping.cs index e410d66ce851..0d7b9c29919a 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselCollectionGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselCollectionGrouping.cs @@ -8,7 +8,7 @@ using osu.Game.Collections; using osu.Game.Screens.Select.Filter; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class TestSceneBeatmapCarouselCollectionGrouping : BeatmapCarouselTestScene diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselDifficultyGrouping.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselDifficultyGrouping.cs index 2cffe60ec1aa..4a41612c3037 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselDifficultyGrouping.cs @@ -5,11 +5,11 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Graphics.Carousel; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osuTK; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class TestSceneBeatmapCarouselDifficultyGrouping : BeatmapCarouselTestScene diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselFiltering.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselFiltering.cs index b1bd9fd3ed50..73596a768d89 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselFiltering.cs @@ -9,11 +9,11 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class TestSceneBeatmapCarouselFiltering : BeatmapCarouselTestScene diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs index c839a2805569..1ff87712de5d 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs @@ -4,12 +4,12 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osuTK; using osuTK.Input; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class TestSceneBeatmapCarouselNoGrouping : BeatmapCarouselTestScene diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselRandom.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselRandom.cs index ce68d587c8ee..66328de8dd5b 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselRandom.cs @@ -5,10 +5,10 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class TestSceneBeatmapCarouselRandom : BeatmapCarouselTestScene diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselScrolling.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselScrolling.cs similarity index 98% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselScrolling.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselScrolling.cs index c1cee4e398b3..319803ef5f44 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselScrolling.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselScrolling.cs @@ -5,9 +5,9 @@ using NUnit.Framework; using osu.Framework.Graphics.Primitives; using osu.Framework.Testing; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class TestSceneBeatmapCarouselScrolling : BeatmapCarouselTestScene diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselSetsSplitApart.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselSetsSplitApart.cs similarity index 70% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselSetsSplitApart.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselSetsSplitApart.cs index fa635f9bde73..12d70702d5cf 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselSetsSplitApart.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselSetsSplitApart.cs @@ -6,10 +6,10 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class TestSceneBeatmapCarouselSetsSplitApart : BeatmapCarouselTestScene @@ -23,6 +23,33 @@ public void SetUpSteps() SortAndGroupBy(SortMode.Title, GroupMode.Length); } + [Test] + public void TestInitialVisualState() + { + AddBeatmaps(3, splitApart: true); + + WaitForDrawablePanels(); + SelectNextSet(); + WaitForSetSelection(set: 0, diff: 0); + + AddAssert("selected item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True); + AddAssert("has visually expanded set", () => Carousel.GetCarouselItems()!.Count(item => item.Model is GroupedBeatmapSet && item.IsExpanded && item.IsVisible), () => Is.EqualTo(1)); + + CreateCarousel(retainSelection: true); + WaitForDrawablePanels(); + WaitForSetSelection(set: 0, diff: 0); + + AddAssert("selected item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True); + AddAssert("has visually expanded set", () => Carousel.GetCarouselItems()!.Count(item => item.Model is GroupedBeatmapSet && item.IsExpanded && item.IsVisible), () => Is.EqualTo(1)); + + CreateCarousel(retainSelection: true); + WaitForDrawablePanels(); + WaitForSetSelection(set: 0, diff: 0); + + AddAssert("selected item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True); + AddAssert("has visually expanded set", () => Carousel.GetCarouselItems()!.Count(item => item.Model is GroupedBeatmapSet && item.IsExpanded && item.IsVisible), () => Is.EqualTo(1)); + } + [Test] public void TestSetTraversal() { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselUpdateHandling.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselUpdateHandling.cs index 17f328b54912..1033a17e05cf 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselUpdateHandling.cs @@ -12,11 +12,11 @@ using osu.Game.Extensions; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] public partial class TestSceneBeatmapCarouselUpdateHandling : BeatmapCarouselTestScene diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs deleted file mode 100644 index 20cc1e544ecf..000000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System.Linq; -using NUnit.Framework; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Screens.Select; - -namespace osu.Game.Tests.Visual.SongSelect -{ - [System.ComponentModel.Description("PlaySongSelect beatmap details")] - public partial class TestSceneBeatmapDetails : OsuTestScene - { - private BeatmapDetails details; - - private DummyAPIAccess api => (DummyAPIAccess)API; - - [SetUp] - public void Setup() => Schedule(() => - { - Child = details = new BeatmapDetails - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(150), - }; - }); - - [Test] - public void TestAllMetrics() - { - AddStep("all metrics", () => details.BeatmapInfo = new APIBeatmap - { - BeatmapSet = new APIBeatmapSet - { - Source = "osu!", - Tags = "this beatmap has all the metrics", - Ratings = Enumerable.Range(0, 11).ToArray(), - }, - DifficultyName = "All Metrics", - CircleSize = 7, - DrainRate = 1, - OverallDifficulty = 5.7f, - ApproachRate = 3.5f, - StarRating = 5.3f, - FailTimes = new APIFailTimes - { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, - }); - } - - [Test] - public void TestAllMetricsExceptSource() - { - AddStep("all except source", () => details.BeatmapInfo = new APIBeatmap - { - BeatmapSet = new APIBeatmapSet - { - Tags = "this beatmap has all the metrics", - Ratings = Enumerable.Range(0, 11).ToArray(), - }, - DifficultyName = "All Metrics", - CircleSize = 7, - DrainRate = 1, - OverallDifficulty = 5.7f, - ApproachRate = 3.5f, - StarRating = 5.3f, - FailTimes = new APIFailTimes - { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, - }); - } - - [Test] - public void TestOnlyRatings() - { - AddStep("ratings", () => details.BeatmapInfo = new APIBeatmap - { - BeatmapSet = new APIBeatmapSet - { - Ratings = Enumerable.Range(0, 11).ToArray(), - Source = "osu!", - Tags = "this beatmap has ratings metrics but not retries or fails", - }, - DifficultyName = "Only Ratings", - CircleSize = 6, - DrainRate = 9, - OverallDifficulty = 6, - ApproachRate = 6, - StarRating = 4.8f, - }); - } - - [Test] - public void TestOnlyFailsAndRetries() - { - AddStep("fails retries", () => details.BeatmapInfo = new APIBeatmap - { - DifficultyName = "Only Retries and Fails", - BeatmapSet = new APIBeatmapSet - { - Source = "osu!", - Tags = "this beatmap has retries and fails but no ratings", - }, - CircleSize = 3.7f, - DrainRate = 6, - OverallDifficulty = 6, - ApproachRate = 7, - StarRating = 2.91f, - FailTimes = new APIFailTimes - { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, - }); - } - - [Test] - public void TestNoMetrics() - { - AddStep("no metrics", () => details.BeatmapInfo = new APIBeatmap - { - DifficultyName = "No Metrics", - BeatmapSet = new APIBeatmapSet - { - Source = "osu!", - Tags = "this beatmap has no metrics", - }, - CircleSize = 5, - DrainRate = 5, - OverallDifficulty = 5.5f, - ApproachRate = 6.5f, - StarRating = 1.97f, - }); - } - - [Test] - public void TestNullBeatmap() - { - AddStep("null beatmap", () => details.BeatmapInfo = null); - } - - [Test] - public void TestOnlineMetrics() - { - AddStep("online ratings/retries/fails", () => details.BeatmapInfo = new APIBeatmap - { - OnlineID = 162, - }); - AddStep("set online", () => api.SetState(APIState.Online)); - AddStep("set offline", () => api.SetState(APIState.Offline)); - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapFilterControl.cs similarity index 93% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapFilterControl.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapFilterControl.cs index 284484d2df42..81f6cbc70d9e 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapFilterControl.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapFilterControl.cs @@ -4,9 +4,9 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneBeatmapFilterControl : SongSelectComponentsTestScene { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs deleted file mode 100644 index 0e0f3c554aa3..000000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ /dev/null @@ -1,305 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Extensions; -using osu.Game.Graphics.Sprites; -using osu.Game.Resources.Localisation.Web; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Catch; -using osu.Game.Rulesets.Mania; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Legacy; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Taiko; -using osu.Game.Screens.Select; -using osuTK; - -namespace osu.Game.Tests.Visual.SongSelect -{ - [TestFixture] - public partial class TestSceneBeatmapInfoWedge : OsuTestScene - { - [Resolved] - private RulesetStore rulesets { get; set; } = null!; - - private TestBeatmapInfoWedge infoWedge = null!; - private readonly List beatmaps = new List(); - - protected override void LoadComplete() - { - base.LoadComplete(); - - Add(infoWedge = new TestBeatmapInfoWedge - { - Size = new Vector2(0.5f, 245), - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Top = 20 } - }); - - AddStep("show", () => infoWedge.Show()); - - selectBeatmap(Beatmap.Value.Beatmap); - - AddWaitStep("wait for select", 3); - - AddStep("hide", () => { infoWedge.Hide(); }); - - AddWaitStep("wait for hide", 3); - - AddStep("show", () => { infoWedge.Show(); }); - - AddSliderStep("change star difficulty", 0, 11.9, 5.55, v => - { - foreach (var hasCurrentValue in infoWedge.Info.ChildrenOfType>()) - hasCurrentValue.Current.Value = new StarDifficulty(v, 0); - }); - - foreach (var rulesetInfo in rulesets.AvailableRulesets) - { - var instance = rulesetInfo.CreateInstance(); - var testBeatmap = CreateTestBeatmap(rulesetInfo); - - beatmaps.Add(testBeatmap); - - setRuleset(rulesetInfo); - - selectBeatmap(testBeatmap); - - testBeatmapLabels(instance); - - switch (instance) - { - case OsuRuleset: - testInfoLabels(5); - break; - - case TaikoRuleset: - testInfoLabels(5); - break; - - case CatchRuleset: - testInfoLabels(5); - break; - - case ManiaRuleset: - testInfoLabels(4); - break; - - default: - testInfoLabels(2); - break; - } - } - } - - private void testBeatmapLabels(Ruleset ruleset) - { - AddAssert("check version", () => infoWedge.Info.VersionLabel.Current.Value == $"{ruleset.ShortName}Version"); - AddAssert("check title", () => infoWedge.Info.TitleLabel.Current.Value == $"{ruleset.ShortName}Title"); - AddAssert("check artist", () => infoWedge.Info.ArtistLabel.Current.Value == $"{ruleset.ShortName}Artist"); - 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); - } - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("reset mods", () => SelectedMods.SetDefault()); - } - - [Test] - public void TestTruncation() - { - selectBeatmap(CreateLongMetadata()); - } - - [Test] - public void TestNullBeatmap() - { - selectBeatmap(null); - 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.ChildrenOfType().Any()); - AddAssert("check no info labels", () => !infoWedge.Info.ChildrenOfType().Any()); - } - - [Test] - public void TestBPMUpdates() - { - const double bpm = 120; - IBeatmap beatmap = CreateTestBeatmap(new OsuRuleset().RulesetInfo); - beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / bpm }); - - OsuModDoubleTime doubleTime = null!; - - selectBeatmap(beatmap); - checkDisplayedBPM($"{bpm}"); - - AddStep("select DT", () => SelectedMods.Value = new[] { doubleTime = new OsuModDoubleTime() }); - checkDisplayedBPM($"{bpm * 1.5f}"); - - AddStep("change DT rate", () => doubleTime.SpeedChange.Value = 2); - checkDisplayedBPM($"{bpm * 2}"); - } - - [TestCase(120, 125, null, "120-125 (mostly 120)")] - [TestCase(120, 120.6, null, "120-121 (mostly 120)")] - [TestCase(120, 120.4, null, "120")] - [TestCase(120, 120.6, "DT", "180-181 (mostly 180)")] - [TestCase(120, 120.4, "DT", "180-181 (mostly 180)")] - public void TestVaryingBPM(double commonBpm, double otherBpm, string? mod, string expectedDisplay) - { - IBeatmap beatmap = CreateTestBeatmap(new OsuRuleset().RulesetInfo); - beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); - beatmap.ControlPointInfo.Add(100, new TimingControlPoint { BeatLength = 60 * 1000 / otherBpm }); - beatmap.ControlPointInfo.Add(200, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); - - if (mod != null) - AddStep($"select {mod}", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateModFromAcronym(mod) }); - - selectBeatmap(beatmap); - checkDisplayedBPM(expectedDisplay); - } - - private void checkDisplayedBPM(string target) - { - AddUntilStep($"displayed bpm is {target}", () => - { - var label = infoWedge.DisplayedContent.ChildrenOfType().Single(l => l.Statistic.Name == BeatmapsetsStrings.ShowStatsBpm); - return label.Statistic.Content == target; - }); - } - - [TestCase] - public void TestLengthUpdates() - { - IBeatmap beatmap = CreateTestBeatmap(new OsuRuleset().RulesetInfo); - double drain = beatmap.CalculateDrainLength(); - beatmap.BeatmapInfo.Length = drain; - - OsuModDoubleTime doubleTime = null!; - - selectBeatmap(beatmap); - checkDisplayedLength(drain); - - AddStep("select DT", () => SelectedMods.Value = new[] { doubleTime = new OsuModDoubleTime() }); - checkDisplayedLength(Math.Round(drain / 1.5f)); - - AddStep("change DT rate", () => doubleTime.SpeedChange.Value = 2); - checkDisplayedLength(Math.Round(drain / 2)); - } - - private void checkDisplayedLength(double drain) - { - var displayedLength = drain.ToFormattedDuration(); - - AddUntilStep($"check map drain ({displayedLength})", () => - { - var label = infoWedge.DisplayedContent.ChildrenOfType() - .Single(l => l.Statistic.Name == BeatmapsetsStrings.ShowStatsTotalLength(displayedLength)); - return label.Statistic.Content == displayedLength.ToString(); - }); - } - - private void setRuleset(RulesetInfo rulesetInfo) - { - Container? containerBefore = null; - - AddStep("set ruleset", () => - { - // wedge content is only refreshed if the ruleset changes, so only wait for load in that case. - if (!rulesetInfo.Equals(Ruleset.Value)) - containerBefore = infoWedge.DisplayedContent; - - Ruleset.Value = rulesetInfo; - }); - - AddUntilStep("wait for async load", () => infoWedge.DisplayedContent != containerBefore); - } - - private void selectBeatmap(IBeatmap? b) - { - Container? containerBefore = null; - - AddStep($"select {b?.Metadata.Title ?? "null"} beatmap", () => - { - containerBefore = infoWedge.DisplayedContent; - infoWedge.Beatmap = Beatmap.Value = b == null ? Beatmap.Default : CreateWorkingBeatmap(b); - }); - - AddUntilStep("wait for async load", () => infoWedge.DisplayedContent != containerBefore); - } - - public static IBeatmap CreateTestBeatmap(RulesetInfo ruleset) - { - List objects = new List(); - for (double i = 0; i < 50000; i += 1000) - objects.Add(new TestHitObject { StartTime = i }); - - return new Beatmap - { - BeatmapInfo = new BeatmapInfo - { - Metadata = new BeatmapMetadata - { - Author = { Username = $"{ruleset.ShortName}Author" }, - Artist = $"{ruleset.ShortName}Artist", - Source = $"{ruleset.ShortName}Source", - Title = $"{ruleset.ShortName}Title" - }, - Ruleset = ruleset, - StarRating = 6, - DifficultyName = $"{ruleset.ShortName}Version", - Difficulty = new BeatmapDifficulty() - }, - HitObjects = objects - }; - } - - public static IBeatmap CreateLongMetadata() - { - return new Beatmap - { - BeatmapInfo = new BeatmapInfo - { - Metadata = new BeatmapMetadata - { - Author = { Username = "WWWWWWWWWWWWWWW" }, - Artist = "Verrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrry long Artist", - Source = "Verrrrry long Source", - Title = "Verrrrry long Title" - }, - DifficultyName = "Verrrrrrrrrrrrrrrrrrrrrrrrrrrrry long Version", - Status = BeatmapOnlineStatus.Graveyard, - }, - }; - } - - private partial class TestBeatmapInfoWedge : BeatmapInfoWedge - { - public new Container DisplayedContent => base.DisplayedContent; - - public new WedgeInfoText Info => base.Info; - } - - private class TestHitObject : ConvertHitObject; - } -} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboardScore.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboardScore.cs index 0aca2d6a1ce5..856a4be67dcc 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboardScore.cs @@ -21,13 +21,13 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osu.Game.Users; using osuTK; using osuTK.Input; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneBeatmapLeaderboardScore : SongSelectComponentsTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardSorting.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboardSorting.cs similarity index 95% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardSorting.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboardSorting.cs index a37700f6bee9..9c50bccd6e7a 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardSorting.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboardSorting.cs @@ -24,11 +24,11 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osu.Game.Users; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneBeatmapLeaderboardSorting : SongSelectComponentsTestScene { @@ -44,7 +44,7 @@ public partial class TestSceneBeatmapLeaderboardSorting : SongSelectComponentsTe private LeaderboardManager leaderboardManager = null!; - private readonly IBindable onlineLookupResult = new Bindable(); + private readonly IBindable onlineLookupResult = new Bindable(); protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboardWedge.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboardWedge.cs index 1c3a5e4babcf..c6d6339c98c9 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboardWedge.cs @@ -25,13 +25,13 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Play.Leaderboards; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osu.Game.Users; using osuTK.Input; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneBeatmapLeaderboardWedge : SongSelectComponentsTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataWedge.cs similarity index 85% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataWedge.cs index d4fab55c6296..769d6bb8f864 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataWedge.cs @@ -7,20 +7,22 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Extensions; +using osu.Game.Graphics.Sprites; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneBeatmapMetadataWedge : SongSelectComponentsTestScene { private BeatmapMetadataWedge wedge = null!; - [Cached(typeof(IBindable))] - private Bindable onlineLookupResult = new Bindable(); + [Cached(typeof(IBindable))] + private Bindable onlineLookupResult = new Bindable(); protected override void LoadComplete() { @@ -53,8 +55,8 @@ public void TestVariousMetrics() working.Metadata.Source = string.Empty; - onlineLookupResult.Value = online; Beatmap.Value = working; + onlineLookupResult.Value = online; }); AddStep("no success rate", () => { @@ -63,8 +65,8 @@ public void TestVariousMetrics() online.Result!.Beatmaps.Single().PlayCount = 0; online.Result!.Beatmaps.Single().PassCount = 0; - onlineLookupResult.Value = online; Beatmap.Value = working; + onlineLookupResult.Value = online; }); AddStep("no user ratings", () => { @@ -72,8 +74,8 @@ public void TestVariousMetrics() online.Result!.Ratings = Array.Empty(); - onlineLookupResult.Value = online; Beatmap.Value = working; + onlineLookupResult.Value = online; }); AddStep("no fail times", () => { @@ -81,8 +83,8 @@ public void TestVariousMetrics() online.Result!.Beatmaps.Single().FailTimes = null; - onlineLookupResult.Value = online; Beatmap.Value = working; + onlineLookupResult.Value = online; }); AddStep("no metrics", () => { @@ -91,8 +93,8 @@ public void TestVariousMetrics() online.Result!.Ratings = Array.Empty(); online.Result!.Beatmaps.Single().FailTimes = null; - onlineLookupResult.Value = online; Beatmap.Value = working; + onlineLookupResult.Value = online; }); AddStep("local beatmap", () => { @@ -100,8 +102,8 @@ public void TestVariousMetrics() working.BeatmapInfo.OnlineID = 0; - onlineLookupResult.Value = null; Beatmap.Value = working; + onlineLookupResult.Value = Screens.Select.SongSelect.BeatmapSetLookupResult.Completed(null); }); } @@ -119,8 +121,8 @@ public void TestTruncation() online.Result!.Language = new BeatmapSetOnlineLanguage { Id = 12, Name = "Verrrrryyyy llooonngggggg language" }; online.Result!.Beatmaps.Single().TopTags = Enumerable.Repeat(online.Result!.Beatmaps.Single().TopTags, 3).SelectMany(t => t!).ToArray(); - onlineLookupResult.Value = online; Beatmap.Value = working; + onlineLookupResult.Value = online; }); } @@ -137,20 +139,28 @@ public void TestOnlineAvailability() working.BeatmapInfo.ResetOnlineInfo(); - onlineLookupResult.Value = lookupResult; Beatmap.Value = working; + onlineLookupResult.Value = lookupResult; }); AddUntilStep("rating wedge hidden", () => !wedge.RatingsVisible); AddUntilStep("fail time wedge hidden", () => !wedge.FailRetryVisible); + + // just check for text everywhere on the wedge as the classes are private and generic + AddAssert("genre is still visible", () => wedge.ChildrenOfType().Any(t => t.Text == "Pop")); + AddAssert("language is still visible", () => wedge.ChildrenOfType().Any(t => t.Text == "English")); + AddStep("local beatmap", () => { var (working, _) = createTestBeatmap(); - onlineLookupResult.Value = null; Beatmap.Value = working; + onlineLookupResult.Value = Screens.Select.SongSelect.BeatmapSetLookupResult.Completed(null); }); AddAssert("rating wedge still hidden", () => !wedge.RatingsVisible); AddAssert("fail time wedge still hidden", () => !wedge.FailRetryVisible); + + AddAssert("genre is cleared", () => wedge.ChildrenOfType().All(t => t.Text != "Pop")); + AddAssert("language is cleared", () => wedge.ChildrenOfType().All(t => t.Text != "English")); } [Test] @@ -166,8 +176,8 @@ public void TestUserTags() online.Result!.RelatedTags = null; working.BeatmapSetInfo.Beatmaps.Single().Metadata.UserTags.Clear(); - onlineLookupResult.Value = online; Beatmap.Value = working; + onlineLookupResult.Value = online; }); } @@ -178,9 +188,9 @@ public void TestLoading() { var (working, online) = createTestBeatmap(); - onlineLookupResult.Value = Screens.SelectV2.SongSelect.BeatmapSetLookupResult.InProgress(); - Scheduler.AddDelayed(() => onlineLookupResult.Value = online, 500); Beatmap.Value = working; + onlineLookupResult.Value = Screens.Select.SongSelect.BeatmapSetLookupResult.InProgress(); + Scheduler.AddDelayed(() => onlineLookupResult.Value = online, 500); }); AddWaitStep("wait", 5); @@ -192,9 +202,9 @@ public void TestLoading() online.Result!.RelatedTags[1].Name = "another/tag"; online.Result!.RelatedTags[2].Name = "some/tag"; - onlineLookupResult.Value = Screens.SelectV2.SongSelect.BeatmapSetLookupResult.InProgress(); - Scheduler.AddDelayed(() => onlineLookupResult.Value = online, 500); Beatmap.Value = working; + onlineLookupResult.Value = Screens.Select.SongSelect.BeatmapSetLookupResult.InProgress(); + Scheduler.AddDelayed(() => onlineLookupResult.Value = online, 500); }); AddWaitStep("wait", 5); @@ -206,9 +216,9 @@ public void TestLoading() online.Result!.RelatedTags = null; working.BeatmapSetInfo.Beatmaps.Single().Metadata.UserTags.Clear(); - onlineLookupResult.Value = Screens.SelectV2.SongSelect.BeatmapSetLookupResult.InProgress(); - Scheduler.AddDelayed(() => onlineLookupResult.Value = online, 500); Beatmap.Value = working; + onlineLookupResult.Value = Screens.Select.SongSelect.BeatmapSetLookupResult.InProgress(); + Scheduler.AddDelayed(() => onlineLookupResult.Value = online, 500); }); AddWaitStep("wait", 5); @@ -220,14 +230,14 @@ public void TestLoading() online.Result!.RelatedTags = null; working.BeatmapSetInfo.Beatmaps.Single().Metadata.UserTags.Clear(); - onlineLookupResult.Value = Screens.SelectV2.SongSelect.BeatmapSetLookupResult.InProgress(); - Scheduler.AddDelayed(() => onlineLookupResult.Value = online, 500); Beatmap.Value = working; + onlineLookupResult.Value = Screens.Select.SongSelect.BeatmapSetLookupResult.InProgress(); + Scheduler.AddDelayed(() => onlineLookupResult.Value = online, 500); }); AddWaitStep("wait", 5); } - private (WorkingBeatmap, Screens.SelectV2.SongSelect.BeatmapSetLookupResult) createTestBeatmap() + private (WorkingBeatmap, Screens.Select.SongSelect.BeatmapSetLookupResult) createTestBeatmap() { var working = CreateWorkingBeatmap(Ruleset.Value); var onlineSet = new APIBeatmapSet @@ -282,7 +292,7 @@ public void TestLoading() working.BeatmapSetInfo.DateSubmitted = DateTimeOffset.Now; working.BeatmapSetInfo.DateRanked = DateTimeOffset.Now; working.Metadata.UserTags.AddRange(onlineSet.RelatedTags.Select(t => t.Name)); - return (working, Screens.SelectV2.SongSelect.BeatmapSetLookupResult.Completed(onlineSet)); + return (working, Screens.Select.SongSelect.BeatmapSetLookupResult.Completed(onlineSet)); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs deleted file mode 100644 index fa4981c137c4..000000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs +++ /dev/null @@ -1,31 +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.ComponentModel; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Screens.Select.Options; - -namespace osu.Game.Tests.Visual.SongSelect -{ - [Description("bottom beatmap details")] - public partial class TestSceneBeatmapOptionsOverlay : OsuTestScene - { - public TestSceneBeatmapOptionsOverlay() - { - var overlay = new BeatmapOptionsOverlay(); - - var colours = new OsuColour(); - - overlay.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, null); - overlay.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, null); - overlay.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, colours.Purple, null); - overlay.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, null); - overlay.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, null); - - Add(overlay); - - AddStep(@"Toggle", overlay.ToggleVisibility); - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index 832e8fc90fb5..f2b4e414700b 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -23,7 +23,7 @@ using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osu.Game.Users; using osu.Game.Utils; diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapTitleWedge.cs similarity index 82% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapTitleWedge.cs index cbcf16ec5179..2421cf7bc316 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapTitleWedge.cs @@ -29,11 +29,10 @@ using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Skinning; -using osu.Game.Tests.Visual.SongSelect; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneBeatmapTitleWedge : SongSelectComponentsTestScene { @@ -42,8 +41,8 @@ public partial class TestSceneBeatmapTitleWedge : SongSelectComponentsTestScene private BeatmapTitleWedge titleWedge = null!; private BeatmapTitleWedge.DifficultyDisplay difficultyDisplay => titleWedge.ChildrenOfType().Single(); - [Cached(typeof(IBindable))] - private Bindable onlineLookupResult = new Bindable(); + [Cached(typeof(IBindable))] + private Bindable onlineLookupResult = new Bindable(); [BackgroundDependencyLoader] private void load(RulesetStore rulesets) @@ -86,7 +85,7 @@ public void TestRulesetChange() foreach (var rulesetInfo in rulesets.AvailableRulesets) { - var testBeatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(rulesetInfo); + var testBeatmap = createTestBeatmapFromRuleset(rulesetInfo); setRuleset(rulesetInfo); selectBeatmap(testBeatmap); @@ -108,7 +107,7 @@ public void TestNullBeatmap() public void TestBPMUpdates() { const double bpm = 120; - IBeatmap beatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(new OsuRuleset().RulesetInfo); + IBeatmap beatmap = createTestBeatmapFromRuleset(new OsuRuleset().RulesetInfo); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / bpm }); OsuModDoubleTime doubleTime = null!; @@ -160,7 +159,7 @@ public void TestOnlineAvailability() var (working, _) = createTestBeatmap(); Beatmap.Value = working; - onlineLookupResult.Value = Screens.SelectV2.SongSelect.BeatmapSetLookupResult.Completed(null); + onlineLookupResult.Value = Screens.Select.SongSelect.BeatmapSetLookupResult.Completed(null); }); AddUntilStep("play count is -", () => this.ChildrenOfType().ElementAt(0).Text.ToString(), () => Is.EqualTo("-")); AddUntilStep("favourites count is -", () => this.ChildrenOfType().Single().Text.ToString(), () => Is.EqualTo("-")); @@ -197,15 +196,15 @@ public void TestFavouriting() AddUntilStep("favourites count is 2345", () => this.ChildrenOfType().Single().Text.ToString(), () => Is.EqualTo("2,345")); AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); - AddStep("allow request to complete", () => resetEvent.Set()); + AddStep("allow request to complete", resetEvent.Set); AddUntilStep("favourites count is 2346", () => this.ChildrenOfType().Single().Text.ToString(), () => Is.EqualTo("2,346")); - AddStep("reset event", () => resetEvent.Reset()); + AddStep("reset event", resetEvent.Reset); AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); - AddStep("allow request to complete", () => resetEvent.Set()); + AddStep("allow request to complete", resetEvent.Set); AddUntilStep("favourites count is 2345", () => this.ChildrenOfType().Single().Text.ToString(), () => Is.EqualTo("2,345")); - AddStep("reset event", () => resetEvent.Reset()); + AddStep("reset event", resetEvent.Reset); AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); AddStep("change to another beatmap", () => { @@ -217,7 +216,7 @@ public void TestFavouriting() Beatmap.Value = working; onlineLookupResult.Value = online; }); - AddStep("allow request to complete", () => resetEvent.Set()); + AddStep("allow request to complete", resetEvent.Set); AddUntilStep("favourites count is 9999", () => this.ChildrenOfType().Single().Text.ToString(), () => Is.EqualTo("9,999")); AddStep("set up request handler to fail", () => @@ -239,13 +238,13 @@ public void TestFavouriting() } }; }); - AddStep("reset event", () => resetEvent.Reset()); + AddStep("reset event", resetEvent.Reset); AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); - AddAssert("spinner visible", () => this.ChildrenOfType().Single() - .ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); - AddStep("allow request to complete", () => resetEvent.Set()); - AddAssert("spinner hidden", () => this.ChildrenOfType().Single() - .ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddUntilStep("spinner visible", () => this.ChildrenOfType().Single() + .ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + AddStep("allow request to complete", resetEvent.Set); + AddUntilStep("spinner hidden", () => this.ChildrenOfType().Single() + .ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); } [TestCase(120, 125, null, "120-125 (mostly 120)")] @@ -255,7 +254,7 @@ public void TestFavouriting() [TestCase(120, 120.4, "DT", "180-181 (mostly 180)")] public void TestVaryingBPM(double commonBpm, double otherBpm, string? mod, string expectedDisplay) { - IBeatmap beatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(new OsuRuleset().RulesetInfo); + IBeatmap beatmap = createTestBeatmapFromRuleset(new OsuRuleset().RulesetInfo); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); beatmap.ControlPointInfo.Add(100, new TimingControlPoint { BeatLength = 60 * 1000 / otherBpm }); beatmap.ControlPointInfo.Add(200, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); @@ -295,11 +294,11 @@ private void checkDisplayedBPM(string target) AddUntilStep($"displayed bpm is {target}", () => { var label = titleWedge.ChildrenOfType().Single(l => l.TooltipText == BeatmapsetsStrings.ShowStatsBpm); - return label.Text == target; + return label.Text.ToString() == target; }); } - private (WorkingBeatmap, Screens.SelectV2.SongSelect.BeatmapSetLookupResult) createTestBeatmap() + private (WorkingBeatmap, Screens.Select.SongSelect.BeatmapSetLookupResult) createTestBeatmap() { var working = CreateWorkingBeatmap(Ruleset.Value); var onlineSet = new APIBeatmapSet @@ -320,7 +319,33 @@ private void checkDisplayedBPM(string target) working.BeatmapSetInfo.DateSubmitted = DateTimeOffset.Now; working.BeatmapSetInfo.DateRanked = DateTimeOffset.Now; - return (working, Screens.SelectV2.SongSelect.BeatmapSetLookupResult.Completed(onlineSet)); + return (working, Screens.Select.SongSelect.BeatmapSetLookupResult.Completed(onlineSet)); + } + + private static IBeatmap createTestBeatmapFromRuleset(RulesetInfo ruleset) + { + List objects = new List(); + for (double i = 0; i < 50000; i += 1000) + objects.Add(new TestHitObject { StartTime = i }); + + return new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Author = { Username = $"{ruleset.ShortName}Author" }, + Artist = $"{ruleset.ShortName}Artist", + Source = $"{ruleset.ShortName}Source", + Title = $"{ruleset.ShortName}Title" + }, + Ruleset = ruleset, + StarRating = 6, + DifficultyName = $"{ruleset.ShortName}Version", + Difficulty = new BeatmapDifficulty() + }, + HitObjects = objects + }; } private class TestHitObject : ConvertHitObject; diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapTitleWedgeStatistic.cs similarity index 97% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapTitleWedgeStatistic.cs index 6bf946902154..f0691f05a185 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapTitleWedgeStatistic.cs @@ -8,10 +8,10 @@ using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Overlays; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Visual.UserInterface; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneBeatmapTitleWedgeStatistic : ThemeComparisonTestScene { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneCollectionDropdown.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneCollectionDropdown.cs index 8525e33a337a..e1c39ce4f24d 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneCollectionDropdown.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneCollectionDropdown.cs @@ -24,9 +24,13 @@ using osu.Game.Tests.Resources; using osuTK.Input; using Realms; +using CollectionDropdown = osu.Game.Screens.Select.CollectionDropdown; namespace osu.Game.Tests.Visual.SongSelect { + /// + /// WARNING: TODO: we have TWO `CollectionDropdowns` with diverging functionality. This is not good. + /// public partial class TestSceneCollectionDropdown : OsuManualInputManagerTestScene { private RulesetStore rulesets = null!; @@ -198,8 +202,6 @@ public void TestButtonAddsAndRemovesBeatmap() [Test] public void TestManageCollectionsFilterIsNotSelected() { - bool received = false; - addExpandHeaderStep(); AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List { "abc" })))); @@ -213,12 +215,6 @@ public void TestManageCollectionsFilterIsNotSelected() addExpandHeaderStep(); - AddStep("watch for filter requests", () => - { - received = false; - dropdown.ChildrenOfType().First().RequestFilter = () => received = true; - }); - AddStep("click manage collections filter", () => { int lastItemIndex = dropdown.ChildrenOfType().Single().Items.Count() - 1; @@ -227,8 +223,6 @@ public void TestManageCollectionsFilterIsNotSelected() }); AddAssert("collection filter still selected", () => dropdown.Current.Value.CollectionName == "1"); - - AddAssert("filter request not fired", () => !received); } private void writeAndRefresh(Action action) => Realm.Write(r => @@ -241,7 +235,7 @@ private void writeAndRefresh(Action action) => Realm.Write(r => private void assertCollectionHeaderDisplays(LocalisableString collectionName, bool shouldDisplay = true) => AddUntilStep($"collection dropdown header displays '{collectionName}'", - () => shouldDisplay == dropdown.ChildrenOfType().Any(h => h.ChildrenOfType().Any(t => t.Text == collectionName))); + () => shouldDisplay == dropdown.ChildrenOfType().Any(h => h.ChildrenOfType().Any(t => t.Text == collectionName))); private void assertFirstButtonIs(IconUsage icon) => AddUntilStep($"button is {icon.Icon.ToString()}", () => getAddOrRemoveButton(1).Icon.Equals(icon)); @@ -255,7 +249,7 @@ private IconButton getAddOrRemoveButton(int index) private void addExpandHeaderStep() => AddStep("expand header", () => { - InputManager.MoveMouseTo(dropdown.ChildrenOfType().Single()); + InputManager.MoveMouseTo(dropdown.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyRangeSlider.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneDifficultyRangeSlider.cs similarity index 96% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyRangeSlider.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneDifficultyRangeSlider.cs index f97af65fd99d..ed7ade491474 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyRangeSlider.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneDifficultyRangeSlider.cs @@ -7,12 +7,12 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Overlays; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Visual.UserInterface; using osuTK; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneDifficultyRangeSlider : ThemeComparisonTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneDifficultyStatisticsDisplay.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneDifficultyStatisticsDisplay.cs index 0ee742a09d58..5b15add2ad08 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneDifficultyStatisticsDisplay.cs @@ -11,10 +11,10 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; using osu.Game.Overlays; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneDifficultyStatisticsDisplay : OsuTestScene { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs deleted file mode 100644 index 41e44357d70d..000000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs +++ /dev/null @@ -1,24 +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.Graphics; -using osu.Game.Screens.Select; - -namespace osu.Game.Tests.Visual.SongSelect -{ - public partial class TestSceneFilterControl : OsuManualInputManagerTestScene - { - [SetUp] - public void SetUp() => Schedule(() => - { - Child = new FilterControl - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = FilterControl.HEIGHT, - }; - }); - } -} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFooterButtonMods.cs similarity index 98% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneFooterButtonMods.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneFooterButtonMods.cs index c339f16bb48a..08d02015a29d 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFooterButtonMods.cs @@ -13,10 +13,10 @@ using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Utils; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneFooterButtonMods : OsuTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelBeatmap.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePanelBeatmap.cs similarity index 98% rename from osu.Game.Tests/Visual/SongSelectV2/TestScenePanelBeatmap.cs rename to osu.Game.Tests/Visual/SongSelect/TestScenePanelBeatmap.cs index 618b9e0d482a..ca71f5a212e6 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelBeatmap.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePanelBeatmap.cs @@ -19,12 +19,12 @@ using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.UserInterface; using osuTK; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestScenePanelBeatmap : ThemeComparisonTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelBeatmapStandalone.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePanelBeatmapStandalone.cs similarity index 98% rename from osu.Game.Tests/Visual/SongSelectV2/TestScenePanelBeatmapStandalone.cs rename to osu.Game.Tests/Visual/SongSelect/TestScenePanelBeatmapStandalone.cs index 67a9f54f1a93..48c0fef48afd 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelBeatmapStandalone.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePanelBeatmapStandalone.cs @@ -19,12 +19,12 @@ using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.UserInterface; using osuTK; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestScenePanelBeatmapStandalone : ThemeComparisonTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePanelGroup.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs rename to osu.Game.Tests/Visual/SongSelect/TestScenePanelGroup.cs index 12557a80f460..62c8a9c8c554 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePanelGroup.cs @@ -7,15 +7,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; -using osu.Game.Overlays; using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Cursor; +using osu.Game.Overlays; using osu.Game.Scoring; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Visual.UserInterface; using osuTK; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestScenePanelGroup : ThemeComparisonTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelSet.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePanelSet.cs similarity index 97% rename from osu.Game.Tests/Visual/SongSelectV2/TestScenePanelSet.cs rename to osu.Game.Tests/Visual/SongSelect/TestScenePanelSet.cs index b574262d55fc..a903f8c83f73 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelSet.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePanelSet.cs @@ -11,12 +11,12 @@ using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Cursor; using osu.Game.Overlays; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.UserInterface; using osuTK; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestScenePanelSet : ThemeComparisonTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelUpdateBeatmapButton.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePanelUpdateBeatmapButton.cs similarity index 95% rename from osu.Game.Tests/Visual/SongSelectV2/TestScenePanelUpdateBeatmapButton.cs rename to osu.Game.Tests/Visual/SongSelect/TestScenePanelUpdateBeatmapButton.cs index 8156842eb982..02715ade3a0a 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelUpdateBeatmapButton.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePanelUpdateBeatmapButton.cs @@ -5,9 +5,9 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Beatmaps; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestScenePanelUpdateBeatmapButton : OsuTestScene { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelect.cs similarity index 91% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneSongSelect.cs index e4f05b2e495d..6eddc8e1d858 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelect.cs @@ -20,19 +20,17 @@ using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.Select.Leaderboards; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; using osuTK.Input; -using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel; -using FooterButtonMods = osu.Game.Screens.SelectV2.FooterButtonMods; -using FooterButtonOptions = osu.Game.Screens.SelectV2.FooterButtonOptions; -using FooterButtonRandom = osu.Game.Screens.SelectV2.FooterButtonRandom; +using BeatmapCarousel = osu.Game.Screens.Select.BeatmapCarousel; +using FooterButtonMods = osu.Game.Screens.Select.FooterButtonMods; +using FooterButtonOptions = osu.Game.Screens.Select.FooterButtonOptions; +using FooterButtonRandom = osu.Game.Screens.Select.FooterButtonRandom; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneSongSelect : SongSelectTestScene { @@ -144,41 +142,6 @@ public void TestInvalidRulesetDoesNotEnterGameplay() void onScreenPushed(IScreen lastScreen, IScreen newScreen) => screensPushed.Add(lastScreen); } - [TestCase(true)] - [TestCase(false)] - public void TestHoveringLeftSideReexpandsGroupSelectionIsIn(bool mouseOverPanel) - { - ImportBeatmapForRuleset(0); - - LoadSongSelect(); - SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty); - - AddStep("move mouse to carousel", () => InputManager.MoveMouseTo(Carousel)); - - AddUntilStep("expanded group is below 1 star", - () => (Carousel.ChildrenOfType().SingleOrDefault(p => p.Expanded.Value)?.Item?.Model as StarDifficultyGroupDefinition)?.Difficulty.Stars, - () => Is.EqualTo(0)); - - AddStep("select next group", () => - { - InputManager.PressKey(Key.ShiftLeft); - InputManager.Key(Key.Right); - InputManager.ReleaseKey(Key.ShiftLeft); - }); - AddUntilStep("expanded group is 3 star", - () => (Carousel.ChildrenOfType().SingleOrDefault(p => p.Expanded.Value)?.Item?.Model as StarDifficultyGroupDefinition)?.Difficulty.Stars, - () => Is.EqualTo(3)); - - if (mouseOverPanel) - AddStep("move mouse over left panel", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); - else - AddStep("move mouse to left side container", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); - - AddUntilStep("expanded group is below 1 star", - () => (Carousel.ChildrenOfType().Single(p => p.Expanded.Value).Item?.Model as StarDifficultyGroupDefinition)?.Difficulty.Stars, - () => Is.EqualTo(0)); - } - #region Hotkeys [Test] @@ -360,7 +323,7 @@ public void TestFilteringRunsAfterReturningFromGameplay() AddStep("exit gameplay", () => Stack.CurrentScreen.Exit()); - AddUntilStep("wait for song select", () => Stack.CurrentScreen is Screens.SelectV2.SongSelect); + AddUntilStep("wait for song select", () => Stack.CurrentScreen is Screens.Select.SongSelect); AddUntilStep("wait for filtered", () => SongSelect.ChildrenOfType().Single().FilterCount, () => Is.EqualTo(2)); } @@ -695,6 +658,51 @@ public void TestFooterOptionsState() AddAssert("options disabled", () => !this.ChildrenOfType().Single().Enabled.Value); } + /// + /// tests that clicking the osu! logo immediately after selecting a different difficulty + /// (before the selection debounce completes) starts the correct beatmap. + /// this tests the fix for https://github.com/ppy/osu/issues/36074 + /// + [Test] + public void TestPlayCorrectBeatmapWhenSelectionNotFullyLoaded() + { + // import a beatmap set with multiple difficulties + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + + // wait for initial beatmap to be selected + AddUntilStep("wait for first beatmap selected", () => !Beatmap.IsDefault); + + BeatmapInfo? firstBeatmap = null; + AddStep("store first difficulty", () => firstBeatmap = Beatmap.Value.BeatmapInfo); + + // start loading the first difficulty + AddStep("click logo to start loading", () => this.ChildrenOfType().Single().TriggerClick()); + AddUntilStep("wait for player loader", () => Stack.CurrentScreen is PlayerLoader); + + // return to song select + AddStep("press escape to return", () => InputManager.Key(Key.Escape)); + AddUntilStep("wait for return to song select", () => SongSelect.IsCurrentScreen()); + + // press down and schedule logo click to happen shortly after (but before 150ms debounce) + // this reproduces the race condition where Beatmap.Value hasn't updated yet + AddStep("select next difficulty and click logo immediately", () => + { + InputManager.Key(Key.Down); + Schedule(() => this.ChildrenOfType().Single().TriggerClick()); + }); + + AddUntilStep("wait for player loader", () => Stack.CurrentScreen is PlayerLoader); + + // verify we're loading the second difficulty, not the first + // without the fix, this would fail because Beatmap.Value still has the old value + AddAssert("player is loading second difficulty", () => + Beatmap.Value.BeatmapInfo.ID != firstBeatmap!.ID); + + AddUntilStep("wait for return to song select", () => SongSelect.IsCurrentScreen()); + } + #endregion } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectCurrentSelectionInvalidated.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectCurrentSelectionInvalidated.cs similarity index 98% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectCurrentSelectionInvalidated.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectCurrentSelectionInvalidated.cs index 0ec61f59dae7..59c9074ddb44 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectCurrentSelectionInvalidated.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectCurrentSelectionInvalidated.cs @@ -8,11 +8,11 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osuTK.Input; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { /// /// The fallback behaviour guaranteed by SongSelect is that a random selection will happen in worst case scenario. @@ -226,7 +226,7 @@ public void TestDebounceNotBypassedOnUpdate() Beatmaps.Delete(Beatmaps.GetAllUsableBeatmapSets().Last()); // check selection during debounce - Scheduler.AddDelayed(() => selectedBeatmapDuringDebounce = Beatmap.Value.BeatmapInfo, Screens.SelectV2.SongSelect.SELECTION_DEBOUNCE / 2f); + Scheduler.AddDelayed(() => selectedBeatmapDuringDebounce = Beatmap.Value.BeatmapInfo, Screens.Select.SongSelect.SELECTION_DEBOUNCE / 2f); }); WaitForFiltering(); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFiltering.cs similarity index 58% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFiltering.cs index eeeb6f72975c..a479a286573c 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFiltering.cs @@ -12,15 +12,17 @@ using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Chat; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; -using FilterControl = osu.Game.Screens.SelectV2.FilterControl; -using NoResultsPlaceholder = osu.Game.Screens.SelectV2.NoResultsPlaceholder; +using osuTK.Input; +using FilterControl = osu.Game.Screens.Select.FilterControl; +using NoResultsPlaceholder = osu.Game.Screens.Select.NoResultsPlaceholder; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneSongSelectFiltering : SongSelectTestScene { @@ -265,6 +267,8 @@ public void TestSelectionRetainedWhenFilteringAllPanelsAway() AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); AddAssert("still has selection", () => Beatmap.IsDefault, () => Is.False); + + AddStep("reset star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMinimum, 0.0)); } [Test] @@ -354,6 +358,238 @@ public void TestCantHideAllBeatmaps() checkMatchedBeatmaps(1); } + [Test] + public void TestScopeToBeatmapWhenDifficultiesSplitApart() + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(SortMode.Difficulty); + checkMatchedBeatmaps(6); + + scopeBeatmap(false); + checkMatchedBeatmaps(3); + + AddStep("press Escape", () => InputManager.Key(Key.Escape)); + WaitForFiltering(); + checkMatchedBeatmaps(6); + } + + [Test] + public void TestScopeToBeatmapWhenDifficultiesGroupedBySet() + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(SortMode.Artist); + checkMatchedBeatmaps(6); + + scopeBeatmap(true); + checkMatchedBeatmaps(3); + + AddStep("press Escape", () => InputManager.Key(Key.Escape)); + WaitForFiltering(); + checkMatchedBeatmaps(6); + } + + [Test] + public void TestDismissingScopeDoesNotClearSearchTextBox() + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(SortMode.Artist); + checkMatchedBeatmaps(6); + + AddStep("set text filter", () => filterTextBox.Current.Value = Beatmaps.GetAllUsableBeatmapSets().First().Metadata.Title); + WaitForFiltering(); + checkMatchedBeatmaps(3); + + scopeBeatmap(true); + checkMatchedBeatmaps(3); + + AddStep("press Escape", () => InputManager.Key(Key.Escape)); + WaitForFiltering(); + checkMatchedBeatmaps(3); + AddAssert("text filter not emptied", () => filterTextBox.Current.Value, () => Is.Not.Empty); + } + + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeRevertsToOriginalSelection(bool grouped) + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(6); + + AddStep("select normal difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Normal"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Normal"))); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(3); + + AddStep("select insane difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Insane"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Insane"))); + + AddStep("exit scoped view", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + WaitForFiltering(); + + checkMatchedBeatmaps(6); + AddAssert("normal difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Normal"))); + + AddStep("reset star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeWhenSelectedBeatmapHiddenByFilters(bool grouped) + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(6); + + AddStep("set star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, findBeatmap("Hard").StarRating + 0.1)); + WaitForFiltering(); + + AddStep("select hard difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Hard"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(3); + + AddStep("select insane difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Insane"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Insane"))); + + AddStep("exit scoped view", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + WaitForFiltering(); + + AddAssert("hard difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + AddStep("reset star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeByChangingRuleset(bool grouped) + { + bool showConverts = Config.Get(OsuSetting.ShowConvertedBeatmaps); + + AddStep("hide converts", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + ImportBeatmapForRuleset(0, 2); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(2); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(2); + + AddStep("select insane difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Insane"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Insane"))); + + AddStep("change ruleset", () => Ruleset.Value = new CatchRuleset().RulesetInfo); + WaitForFiltering(); + + AddAssert("hard catch difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + AddStep("revert convert setting", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, showConverts)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeByShowingConverts(bool grouped) + { + bool showConverts = Config.Get(OsuSetting.ShowConvertedBeatmaps); + + AddStep("hide converts", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(6); + + AddStep("set star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, Beatmap.Value.BeatmapSetInfo.Beatmaps.ElementAt(1).StarRating + 0.1)); + WaitForFiltering(); + + AddStep("select hard difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Hard"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(3); + + AddStep("select insane difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Insane"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Insane"))); + + AddStep("show converts", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + WaitForFiltering(); + + AddAssert("hard difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + AddStep("revert convert setting", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, showConverts)); + AddStep("reset star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeByChangingFilterText(bool grouped) + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(6); + + AddStep("select hard difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Hard"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(3); + + AddStep("set filter text", () => filterTextBox.Current.Value = findBeatmap("Normal").DifficultyName); + WaitForFiltering(); + + AddAssert("normal difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Normal"))); + } + + private void scopeBeatmap(bool grouped) + { + if (grouped) + { + AddUntilStep("wait for spread indicator", () => this.ChildrenOfType().Any(d => d.Enabled.Value)); + AddStep("click spread indicator", () => this.ChildrenOfType().Single(d => d.Enabled.Value).TriggerClick()); + } + else + { + AddUntilStep("wait for spread indicator", () => this.ChildrenOfType().Any(d => d.Enabled.Value)); + AddStep("click spread indicator", () => this.ChildrenOfType().Single(d => d.Enabled.Value).TriggerClick()); + } + + WaitForFiltering(); + } + + private BeatmapInfo findBeatmap(string difficultySubstring) => Beatmap.Value.BeatmapSetInfo.Beatmaps.First(b => b.DifficultyName.Contains(difficultySubstring)); + private NoResultsPlaceholder? getPlaceholder() => SongSelect.ChildrenOfType().FirstOrDefault(); private void checkMatchedBeatmaps(int expected) => AddUntilStep($"{expected} matching shown", () => Carousel.MatchedBeatmapsCount, () => Is.EqualTo(expected)); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs deleted file mode 100644 index 646dedc2be78..000000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System.Linq; -using NUnit.Framework; -using osu.Framework.Graphics; -using osu.Framework.Testing; -using osu.Game.Screens.Select; -using osuTK; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.SongSelect -{ - public partial class TestSceneSongSelectFooter : OsuManualInputManagerTestScene - { - private FooterButtonRandom randomButton; - - private bool nextRandomCalled; - private bool previousRandomCalled; - - [SetUp] - public void SetUp() => Schedule(() => - { - nextRandomCalled = false; - previousRandomCalled = false; - - Footer footer; - - Child = footer = new Footer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; - - footer.AddButton(new FooterButtonMods(), null); - footer.AddButton(randomButton = new FooterButtonRandom - { - NextRandom = () => nextRandomCalled = true, - PreviousRandom = () => previousRandomCalled = true, - }, null); - footer.AddButton(new FooterButtonOptions(), null); - - InputManager.MoveMouseTo(Vector2.Zero); - }); - - [Test] - public void TestState() - { - AddRepeatStep("toggle options state", () => this.ChildrenOfType().Last().Enabled.Toggle(), 20); - } - - [Test] - public void TestFooterRandom() - { - AddStep("press F2", () => InputManager.Key(Key.F2)); - AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled); - } - - [Test] - public void TestFooterRandomViaMouse() - { - AddStep("click button", () => - { - InputManager.MoveMouseTo(randomButton); - InputManager.Click(MouseButton.Left); - }); - AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled); - } - - [Test] - public void TestFooterRewind() - { - AddStep("press Shift+F2", () => - { - InputManager.PressKey(Key.LShift); - InputManager.PressKey(Key.F2); - InputManager.ReleaseKey(Key.F2); - InputManager.ReleaseKey(Key.LShift); - }); - AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); - } - - [Test] - public void TestFooterRewindViaShiftMouseLeft() - { - AddStep("shift + click button", () => - { - InputManager.PressKey(Key.LShift); - InputManager.MoveMouseTo(randomButton); - InputManager.Click(MouseButton.Left); - InputManager.ReleaseKey(Key.LShift); - }); - AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); - } - - [Test] - public void TestFooterRewindViaMouseRight() - { - AddStep("right click button", () => - { - InputManager.MoveMouseTo(randomButton); - InputManager.Click(MouseButton.Right); - }); - AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectGrouping.cs similarity index 99% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectGrouping.cs index e65c9553c246..eb282d3d3c9b 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectGrouping.cs @@ -16,11 +16,11 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Scoring; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.SongSelect { /// /// Test suite for grouping modes which require the presence of API / realm. diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs deleted file mode 100644 index cb0845ede877..000000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs +++ /dev/null @@ -1,224 +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.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Extensions; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; -using osu.Framework.Platform; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; -using osu.Game.Scoring; -using osu.Game.Screens.Select.Carousel; -using osu.Game.Tests.Resources; -using osuTK; - -namespace osu.Game.Tests.Visual.SongSelect -{ - public partial class TestSceneTopLocalRank : OsuTestScene - { - private RulesetStore rulesets = null!; - private BeatmapManager beatmapManager = null!; - private ScoreManager scoreManager = null!; - private TopLocalRank topLocalRank = null!; - - [BackgroundDependencyLoader] - private void load(GameHost host, AudioManager audio) - { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(scoreManager = new ScoreManager(rulesets, () => beatmapManager, LocalStorage, Realm, API)); - Dependencies.Cache(Realm); - - beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); - } - - private BeatmapInfo importedBeatmap => beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(b => b.Ruleset.ShortName == "osu"); - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("Delete all scores", () => scoreManager.Delete()); - - AddStep("Create local rank", () => - { - Child = topLocalRank = new TopLocalRank(importedBeatmap) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(10), - }; - }); - - AddAssert("No rank displayed initially", () => topLocalRank.DisplayedRank == null); - } - - [Test] - public void TestBasicImportDelete() - { - ScoreInfo testScoreInfo = null!; - - AddStep("Add score for current user", () => - { - testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); - - testScoreInfo.User = API.LocalUser.Value; - testScoreInfo.Rank = ScoreRank.B; - - scoreManager.Import(testScoreInfo); - }); - - AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); - - AddStep("Delete score", () => scoreManager.Delete(testScoreInfo)); - - AddUntilStep("No rank displayed", () => topLocalRank.DisplayedRank == null); - } - - [Test] - public void TestRulesetChange() - { - AddStep("Add score for current user", () => - { - var testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); - - testScoreInfo.User = API.LocalUser.Value; - testScoreInfo.Rank = ScoreRank.B; - - scoreManager.Import(testScoreInfo); - }); - - AddUntilStep("Wait for initial display", () => topLocalRank.DisplayedRank == ScoreRank.B); - - AddStep("Change ruleset", () => Ruleset.Value = rulesets.GetRuleset("fruits")); - AddUntilStep("No rank displayed", () => topLocalRank.DisplayedRank == null); - - AddStep("Change ruleset back", () => Ruleset.Value = rulesets.GetRuleset("osu")); - AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); - } - - [Test] - public void TestHigherScoreSet() - { - AddStep("Add score for current user", () => - { - var testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); - - testScoreInfo.User = API.LocalUser.Value; - testScoreInfo.Rank = ScoreRank.B; - - scoreManager.Import(testScoreInfo); - }); - - AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); - - AddStep("Add higher score for current user", () => - { - var testScoreInfo2 = TestResources.CreateTestScoreInfo(importedBeatmap); - - testScoreInfo2.User = API.LocalUser.Value; - testScoreInfo2.Rank = ScoreRank.X; - testScoreInfo2.TotalScore = 1000000; - testScoreInfo2.Statistics = testScoreInfo2.MaximumStatistics; - - scoreManager.Import(testScoreInfo2); - }); - - AddUntilStep("SS rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.X); - } - - [Test] - public void TestLegacyScore() - { - ScoreInfo testScoreInfo = null!; - - AddStep("Add legacy score for current user", () => - { - testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); - - testScoreInfo.User = API.LocalUser.Value; - testScoreInfo.Rank = ScoreRank.B; - - scoreManager.Import(testScoreInfo); - }); - - AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); - - AddStep("Add higher-graded score for current user", () => - { - var testScoreInfo2 = TestResources.CreateTestScoreInfo(importedBeatmap); - - testScoreInfo2.User = API.LocalUser.Value; - testScoreInfo2.Rank = ScoreRank.X; - testScoreInfo2.Statistics = testScoreInfo2.MaximumStatistics; - testScoreInfo2.TotalScore = testScoreInfo.TotalScore + 1; - - scoreManager.Import(testScoreInfo2); - }); - - AddUntilStep("SS rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.X); - } - - [Test] - public void TestGuestScore() - { - AddStep("Add score for guest user", () => - { - var testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); - - testScoreInfo.User = new GuestUser(); - testScoreInfo.Rank = ScoreRank.B; - - scoreManager.Import(testScoreInfo); - }); - - AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank, () => Is.EqualTo(ScoreRank.B)); - } - - [Test] - public void TestUnknownUserScore() - { - AddStep("Add score for unknown user", () => - { - var testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); - - testScoreInfo.User = new APIUser { Username = "AAA", }; - testScoreInfo.Rank = ScoreRank.S; - - scoreManager.Import(testScoreInfo); - }); - - AddUntilStep("S rank displayed", () => topLocalRank.DisplayedRank, () => Is.EqualTo(ScoreRank.S)); - } - - [Test] - public void TestAnotherUserScore() - { - AddStep("Add score for not-current user", () => - { - var testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); - - testScoreInfo.User = new APIUser { Username = "notme", Id = 43, }; - testScoreInfo.Rank = ScoreRank.S; - - scoreManager.Import(testScoreInfo); - }); - - AddUntilStep("No rank displayed", () => topLocalRank.DisplayedRank, () => Is.Null); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (rulesets.IsNotNull()) - rulesets.Dispose(); - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs deleted file mode 100644 index 2311c360ffc7..000000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs +++ /dev/null @@ -1,262 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Online.API; -using osu.Game.Overlays; -using osu.Game.Overlays.Dialog; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Carousel; -using osu.Game.Screens.Select.Filter; -using osu.Game.Tests.Beatmaps; -using osu.Game.Tests.Online; -using osu.Game.Tests.Resources; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.SongSelect -{ - [TestFixture] - public partial class TestSceneUpdateBeatmapSetButton : OsuManualInputManagerTestScene - { - private BeatmapCarousel carousel = null!; - - private TestScenePlaylistsBeatmapAvailabilityTracker.TestBeatmapModelDownloader beatmapDownloader = null!; - - private BeatmapSetInfo testBeatmapSetInfo = null!; - - [Cached(typeof(BeatmapStore))] - private TestBeatmapStore beatmaps = new TestBeatmapStore(); - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - - var importer = parent.Get(); - - dependencies.CacheAs(beatmapDownloader = new TestScenePlaylistsBeatmapAvailabilityTracker.TestBeatmapModelDownloader(importer, API)); - return dependencies; - } - - private UpdateBeatmapSetButton? getUpdateButton() => carousel.ChildrenOfType().SingleOrDefault(); - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("create carousel", () => Child = createCarousel()); - - AddUntilStep("wait for load", () => carousel.BeatmapSetsLoaded); - - AddAssert("update button not visible", () => getUpdateButton() == null); - } - - [Test] - public void TestDownloadToCompletion() - { - ArchiveDownloadRequest? downloadRequest = null; - - AddStep("update online hash", () => - { - testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; - testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; - - carousel.UpdateBeatmapSet(testBeatmapSetInfo); - }); - - AddUntilStep("only one set visible", () => carousel.ChildrenOfType().Count() == 1); - AddUntilStep("update button visible", () => getUpdateButton() != null); - - AddStep("click button", () => getUpdateButton()?.TriggerClick()); - - AddUntilStep("wait for download started", () => - { - downloadRequest = beatmapDownloader.GetExistingDownload(testBeatmapSetInfo); - return downloadRequest != null; - }); - - AddUntilStep("wait for button disabled", () => getUpdateButton()?.Enabled.Value == false); - - AddUntilStep("progress download to completion", () => - { - if (downloadRequest is TestScenePlaylistsBeatmapAvailabilityTracker.TestDownloadRequest testRequest) - { - testRequest.SetProgress(testRequest.Progress + 0.1f); - - if (testRequest.Progress >= 1) - { - testRequest.TriggerSuccess(); - - // usually this would be done by the import process. - testBeatmapSetInfo.Beatmaps.First().MD5Hash = "different hash"; - testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; - - // usually this would be done by a realm subscription. - carousel.UpdateBeatmapSet(testBeatmapSetInfo); - return true; - } - } - - return false; - }); - } - - [Test] - public void TestDownloadFailed() - { - ArchiveDownloadRequest? downloadRequest = null; - - AddStep("update online hash", () => - { - testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; - testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; - - carousel.UpdateBeatmapSet(testBeatmapSetInfo); - }); - - AddUntilStep("only one set visible", () => carousel.ChildrenOfType().Count() == 1); - AddUntilStep("update button visible", () => getUpdateButton() != null); - - AddStep("click button", () => getUpdateButton()?.TriggerClick()); - - AddUntilStep("wait for download started", () => - { - downloadRequest = beatmapDownloader.GetExistingDownload(testBeatmapSetInfo); - return downloadRequest != null; - }); - - AddUntilStep("wait for button disabled", () => getUpdateButton()?.Enabled.Value == false); - - AddUntilStep("progress download to failure", () => - { - if (downloadRequest is TestScenePlaylistsBeatmapAvailabilityTracker.TestDownloadRequest testRequest) - { - testRequest.SetProgress(testRequest.Progress + 0.1f); - - if (testRequest.Progress >= 0.5f) - { - testRequest.TriggerFailure(new InvalidOperationException()); - return true; - } - } - - return false; - }); - - AddUntilStep("wait for button enabled", () => getUpdateButton()?.Enabled.Value == true); - } - - [Test] - public void TestUpdateLocalBeatmap() - { - DialogOverlay dialogOverlay = null!; - UpdateBeatmapSetButton? updateButton = null; - - AddStep("create carousel with dialog overlay", () => - { - dialogOverlay = new DialogOverlay(); - - Child = new DependencyProvidingContainer - { - RelativeSizeAxes = Axes.Both, - CachedDependencies = new (Type, object)[] { (typeof(IDialogOverlay), dialogOverlay), }, - Children = new Drawable[] - { - createCarousel(), - dialogOverlay, - }, - }; - }); - - AddStep("setup beatmap state", () => - { - testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; - testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; - testBeatmapSetInfo.Status = BeatmapOnlineStatus.LocallyModified; - - carousel.UpdateBeatmapSet(testBeatmapSetInfo); - }); - - AddUntilStep("wait for update button", () => (updateButton = getUpdateButton()) != null); - AddStep("click button", () => updateButton.AsNonNull().TriggerClick()); - - AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is UpdateLocalConfirmationDialog); - AddStep("click confirmation", () => - { - InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().First()); - InputManager.PressButton(MouseButton.Left); - }); - - AddUntilStep("update started", () => beatmapDownloader.GetExistingDownload(testBeatmapSetInfo) != null); - AddStep("release mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); - } - - [Test] - public void TestSplitDisplay() - { - ArchiveDownloadRequest? downloadRequest = null; - - AddStep("set difficulty sort mode", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty })); - AddStep("update online hash", () => - { - testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; - testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; - - carousel.UpdateBeatmapSet(testBeatmapSetInfo); - }); - - AddUntilStep("multiple \"sets\" visible", () => carousel.ChildrenOfType().Count(), () => Is.GreaterThan(1)); - AddUntilStep("update button visible", getUpdateButton, () => Is.Not.Null); - - AddStep("click button", () => getUpdateButton()?.TriggerClick()); - - AddUntilStep("wait for download started", () => - { - downloadRequest = beatmapDownloader.GetExistingDownload(testBeatmapSetInfo); - return downloadRequest != null; - }); - - AddUntilStep("wait for button disabled", () => getUpdateButton()?.Enabled.Value == false); - - AddUntilStep("progress download to completion", () => - { - if (downloadRequest is TestScenePlaylistsBeatmapAvailabilityTracker.TestDownloadRequest testRequest) - { - testRequest.SetProgress(testRequest.Progress + 0.1f); - - if (testRequest.Progress >= 1) - { - testRequest.TriggerSuccess(); - - // usually this would be done by the import process. - testBeatmapSetInfo.Beatmaps.First().MD5Hash = "different hash"; - testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; - - // usually this would be done by a realm subscription. - carousel.UpdateBeatmapSet(testBeatmapSetInfo); - return true; - } - } - - return false; - }); - } - - private BeatmapCarousel createCarousel() - { - beatmaps.BeatmapSets.Clear(); - beatmaps.BeatmapSets.Add(testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(5)); - - return carousel = new BeatmapCarousel(new FilterCriteria()) - { - RelativeSizeAxes = Axes.Both, - }; - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs deleted file mode 100644 index ce671c7e7fb6..000000000000 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using NUnit.Framework; -using osu.Framework.Testing; -using osu.Framework.Threading; -using osu.Framework.Utils; -using osu.Game.Beatmaps; -using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; -using osu.Game.Tests.Resources; - -namespace osu.Game.Tests.Visual.SongSelectV2 -{ - /// - /// Covers common steps which can be used for manual testing. - /// - [TestFixture] - public partial class TestSceneBeatmapCarousel : BeatmapCarouselTestScene - { - [Test] - [Explicit] - public void TestBasics() - { - CreateCarousel(); - RemoveAllBeatmaps(); - - AddBeatmaps(10, randomMetadata: true); - AddBeatmaps(10); - AddBeatmaps(1); - } - - [Test] - [Explicit] - public void TestSorting() - { - SortAndGroupBy(SortMode.Artist, GroupMode.None); - SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty); - SortAndGroupBy(SortMode.Artist, GroupMode.Artist); - } - - [Test] - [Explicit] - public void TestRemovals() - { - RemoveFirstBeatmap(); - RemoveAllBeatmaps(); - } - - [Test] - [Explicit] - public void TestLoadingDisplay() - { - AddStep("induce slow filtering", () => Carousel.FilterDelay = 2000); - SortAndGroupBy(SortMode.Artist, GroupMode.None); - } - - [Test] - [Explicit] - public void TestAddRemoveRepeatedOps() - { - AddRepeatStep("add beatmaps", () => BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20); - AddRepeatStep("remove beatmaps", () => BeatmapSets.RemoveAt(RNG.Next(0, BeatmapSets.Count)), 20); - } - - [Test] - [Explicit] - public void TestMasking() - { - AddStep("disable masking", () => Scroll.Masking = false); - AddStep("enable masking", () => Scroll.Masking = true); - } - - [Test] - [Explicit] - public void TestRandomStatus() - { - SortBy(SortMode.Title); - AddStep("add beatmaps", () => - { - for (int i = 0; i < 50; i++) - { - var set = TestResources.CreateTestBeatmapSetInfo(); - set.Status = Enum.GetValues().MinBy(_ => RNG.Next()); - - if (i % 2 == 0) - set.Status = BeatmapOnlineStatus.None; - - BeatmapSets.Add(set); - } - }); - } - - [Test] - public void TestHighChurnUpdatesStillShowsPanels() - { - ScheduledDelegate updateTask = null!; - - AddBeatmaps(1, 1); - - AddStep("start constantly updating beatmap in background", () => - { - updateTask = Scheduler.AddDelayed(() => { BeatmapSets.ReplaceRange(0, 1, [BeatmapSets.First()]); }, 1, true); - }); - - CreateCarousel(); - - AddUntilStep("panels loaded", () => Carousel.ChildrenOfType(), () => Is.Not.Empty); - - AddStep("end task", () => updateTask.Cancel()); - } - - [Test] - [Explicit] - public void TestPerformanceWithManyBeatmaps() - { - const int count = 200000; - - List generated = new List(); - - AddStep($"populate {count} test beatmaps", () => - { - generated.Clear(); - Task.Run(() => - { - for (int j = 0; j < count; j++) - generated.Add(CreateTestBeatmapSetInfo(3, true)); - }).ConfigureAwait(true); - }); - - AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3)); - AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2)); - AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count)); - - AddStep("add all beatmaps", () => BeatmapSets.AddRange(generated)); - } - - [Test] - public void TestSingleItemDisplayed() - { - CreateCarousel(); - RemoveAllBeatmaps(); - - SortAndGroupBy(SortMode.Difficulty, GroupMode.None); - AddBeatmaps(1, fixedDifficultiesPerSet: 1); - AddUntilStep("single item is shown", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs deleted file mode 100644 index e247b92f528a..000000000000 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Testing; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Screens.Footer; -using osu.Game.Screens.SelectV2; - -namespace osu.Game.Tests.Visual.SongSelectV2 -{ - public partial class TestSceneScreenFooter : OsuManualInputManagerTestScene - { - private DependencyProvidingContainer contentContainer = null!; - private ScreenFooter screenFooter = null!; - private UserModSelectOverlay modOverlay = null!; - - [SetUp] - public void SetUp() => Schedule(() => - { - screenFooter = new ScreenFooter(); - - Child = contentContainer = new DependencyProvidingContainer - { - RelativeSizeAxes = Axes.Both, - CachedDependencies = new (Type, object)[] - { - (typeof(ScreenFooter), screenFooter) - }, - Children = new Drawable[] - { - modOverlay = new UserModSelectOverlay { ShowPresets = true }, - new PopoverContainer - { - RelativeSizeAxes = Axes.Both, - Depth = float.MinValue, - Child = screenFooter, - }, - }, - }; - - screenFooter.SetButtons(new ScreenFooterButton[] - { - new FooterButtonMods(modOverlay) { Current = SelectedMods }, - new FooterButtonRandom(), - new FooterButtonOptions(), - }); - }); - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("show footer", () => screenFooter.Show()); - } - - /// - /// Transition when moving from a screen with no buttons to a screen with buttons. - /// - [Test] - public void TestButtonsIn() - { - } - - /// - /// Transition when moving from a screen with buttons to a screen with no buttons. - /// - [Test] - public void TestButtonsOut() - { - AddStep("clear buttons", () => screenFooter.SetButtons(Array.Empty())); - } - - /// - /// Transition when moving from a screen with buttons to a screen with buttons. - /// - [Test] - public void TestReplaceButtons() - { - AddStep("replace buttons", () => screenFooter.SetButtons(new[] - { - new ScreenFooterButton { Text = "One", Action = () => { } }, - new ScreenFooterButton { Text = "Two", Action = () => { } }, - new ScreenFooterButton { Text = "Three", Action = () => { } }, - })); - } - - [Test] - public void TestExternalOverlayContent() - { - TestShearedOverlayContainer externalOverlay = null!; - - AddStep("add overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); - AddStep("set buttons", () => screenFooter.SetButtons(new[] - { - new ScreenFooterButton(externalOverlay) - { - AccentColour = Dependencies.Get().Orange1, - Icon = FontAwesome.Solid.Toolbox, - Text = "One", - }, - new ScreenFooterButton { Text = "Two", Action = () => { } }, - new ScreenFooterButton { Text = "Three", Action = () => { } }, - })); - AddWaitStep("wait for transition", 3); - - AddStep("show overlay", () => externalOverlay.Show()); - contentDisplayed(); - AddUntilStep("other buttons hidden", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.Child.Parent!.Y > 0)); - - AddStep("hide overlay", () => externalOverlay.Hide()); - contentHidden(); - AddUntilStep("other buttons returned", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.ChildrenOfType().First().Y == 0)); - } - - [Test] - public void TestTemporarilyShowFooter() - { - TestShearedOverlayContainer externalOverlay = null!; - - AddStep("hide footer", () => screenFooter.Hide()); - AddStep("remove buttons", () => screenFooter.SetButtons(Array.Empty())); - - AddStep("add external overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); - AddStep("show external overlay", () => externalOverlay.Show()); - AddAssert("footer shown", () => screenFooter.State.Value == Visibility.Visible); - contentDisplayed(); - - AddStep("hide external overlay", () => externalOverlay.Hide()); - AddAssert("footer hidden", () => screenFooter.State.Value == Visibility.Hidden); - contentHidden(); - - AddStep("show footer", () => screenFooter.Show()); - AddAssert("content still hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); - - AddStep("show external overlay", () => externalOverlay.Show()); - AddAssert("footer still visible", () => screenFooter.State.Value == Visibility.Visible); - - AddStep("hide external overlay", () => externalOverlay.Hide()); - AddAssert("footer still visible", () => screenFooter.State.Value == Visibility.Visible); - - AddStep("hide footer", () => screenFooter.Hide()); - AddStep("show external overlay", () => externalOverlay.Show()); - } - - [Test] - public void TestBackButton() - { - TestShearedOverlayContainer externalOverlay = null!; - - AddStep("hide footer", () => screenFooter.Hide()); - AddStep("remove buttons", () => screenFooter.SetButtons(Array.Empty())); - - AddStep("add external overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); - AddStep("show external overlay", () => externalOverlay.Show()); - AddAssert("footer shown", () => screenFooter.State.Value == Visibility.Visible); - - AddStep("press back", () => this.ChildrenOfType().Single().TriggerClick()); - AddAssert("overlay hidden", () => externalOverlay.State.Value == Visibility.Hidden); - AddAssert("footer hidden", () => screenFooter.State.Value == Visibility.Hidden); - - AddStep("show external overlay", () => externalOverlay.Show()); - AddStep("set block count", () => externalOverlay.BackButtonCount = 1); - AddStep("press back", () => this.ChildrenOfType().Single().TriggerClick()); - AddAssert("overlay still visible", () => externalOverlay.State.Value == Visibility.Visible); - AddAssert("footer still shown", () => screenFooter.State.Value == Visibility.Visible); - AddStep("press back again", () => this.ChildrenOfType().Single().TriggerClick()); - AddAssert("overlay hidden", () => externalOverlay.State.Value == Visibility.Hidden); - AddAssert("footer hidden", () => screenFooter.State.Value == Visibility.Hidden); - } - - [Test] - public void TestLoadOverlayAfterFooterIsDisplayed() - { - TestShearedOverlayContainer externalOverlay = null!; - - AddStep("show mod overlay", () => modOverlay.Show()); - AddUntilStep("mod footer content shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.True); - - AddStep("add external overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); - AddUntilStep("wait for load", () => externalOverlay.IsLoaded); - AddAssert("mod footer content still shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.True); - AddAssert("external overlay content not shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.Not.True); - - AddStep("hide mod overlay", () => modOverlay.Hide()); - AddUntilStep("mod footer content hidden", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.Not.True); - AddAssert("external overlay content still not shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.Not.True); - } - - [Test] - public void TestButtonResizedAfterFooterIsDisplayed() - { - TestShearedOverlayContainer externalOverlay = null!; - - AddStep("add overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); - AddStep("set buttons", () => screenFooter.SetButtons(new[] - { - new ScreenFooterButton(externalOverlay) - { - AccentColour = Dependencies.Get().Orange1, - Icon = FontAwesome.Solid.Toolbox, - Text = "One", - }, - new ScreenFooterButton { Text = "Two", Action = () => { } }, - new ScreenFooterButton { Text = "Three", Action = () => { } }, - })); - AddWaitStep("wait for transition", 3); - - AddStep("show overlay", () => externalOverlay.Show()); - contentDisplayed(); - AddUntilStep("other buttons hidden", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.Child.Parent!.Y > 0)); - - AddStep("resize active button", () => this.ChildrenOfType().First().ResizeWidthTo(240, 300, Easing.OutQuint)); - AddStep("resize active button back", () => this.ChildrenOfType().First().ResizeWidthTo(116, 300, Easing.OutQuint)); - - AddStep("hide overlay", () => externalOverlay.Hide()); - contentHidden(); - AddUntilStep("other buttons returned", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.ChildrenOfType().First().Y == 0)); - } - - private void contentHidden() - { - AddUntilStep("content hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); - } - - private void contentDisplayed() - { - AddUntilStep("content displayed in footer", () => screenFooter.ChildrenOfType().Single().IsPresent); - } - - private partial class TestShearedOverlayContainer : ShearedOverlayContainer - { - public TestShearedOverlayContainer() - : base(OverlayColourScheme.Orange) - { - } - - [BackgroundDependencyLoader] - private void load() - { - Header.Title = "Test overlay"; - Header.Description = "An overlay that is made purely for testing purposes."; - } - - public int BackButtonCount; - - public override bool OnBackButton() - { - if (BackButtonCount > 0) - { - BackButtonCount--; - return true; - } - - return false; - } - - public override VisibilityContainer CreateFooterContent() => new TestFooterContent(); - - public partial class TestFooterContent : VisibilityContainer - { - [BackgroundDependencyLoader] - private void load() - { - AutoSizeAxes = Axes.Both; - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Children = new[] - { - new ShearedButton(200) { Text = "Action #1", Action = () => { } }, - new ShearedButton(140) { Text = "Action #2", Action = () => { } }, - } - }; - } - - protected override void PopIn() - { - this.MoveToY(0, 400, Easing.OutQuint) - .FadeIn(400, Easing.OutQuint); - } - - protected override void PopOut() - { - this.MoveToY(-20f, 200, Easing.OutQuint) - .FadeOut(200, Easing.OutQuint); - } - } - } - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs index 5acd6cb0847b..36dbed3742ee 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs @@ -208,13 +208,13 @@ public TestDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) => new DifficultyAttributes(mods, mods.OfType().SingleOrDefault()?.Difficulty.Value ?? 0); - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Array.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonsInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonsInput.cs index 985f613b63b2..5c98f27d8618 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonsInput.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonsInput.cs @@ -72,12 +72,13 @@ public TestSceneButtonsInput() Enabled = { Value = true }, Text = "Rounded button" }, - shearedButton = new ShearedButton(width) + shearedButton = new ShearedButton { Text = "Sheared button", LighterColour = Colour4.FromHex("#FFFFFF"), DarkerColour = Colour4.FromHex("#FFCC22"), TextColour = Colour4.Black, + Width = width, Height = 40, Enabled = { Value = true }, Padding = new MarginPadding(0) @@ -91,7 +92,7 @@ public void TestSettingsButtonInput() { AddStep("Move cursor to button", () => InputManager.MoveMouseTo(settingsButton)); AddAssert("Button is hovered", () => settingsButton.IsHovered); - AddStep("Move cursor to padded area", () => InputManager.MoveMouseTo(settingsButton.ScreenSpaceDrawQuad.TopLeft + new Vector2(SettingsPanel.CONTENT_MARGINS / 2f, 10))); + AddStep("Move cursor to padded area", () => InputManager.MoveMouseTo(settingsButton.ScreenSpaceDrawQuad.TopLeft + new Vector2(SettingsPanel.CONTENT_PADDING.Left / 2f, 10))); AddAssert("Cursor within a button", () => settingsButton.ScreenSpaceDrawQuad.Contains(InputManager.CurrentState.Mouse.Position)); AddAssert("Button is not hovered", () => !settingsButton.IsHovered); } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneCollectionDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCollectionDropdown.cs similarity index 95% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneCollectionDropdown.cs rename to osu.Game.Tests/Visual/UserInterface/TestSceneCollectionDropdown.cs index 8cee78e0b8d2..c5a0d7eab8a8 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneCollectionDropdown.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCollectionDropdown.cs @@ -24,9 +24,8 @@ using osu.Game.Tests.Resources; using osuTK.Input; using Realms; -using CollectionDropdown = osu.Game.Screens.SelectV2.CollectionDropdown; -namespace osu.Game.Tests.Visual.SongSelectV2 +namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneCollectionDropdown : OsuManualInputManagerTestScene { @@ -199,6 +198,8 @@ public void TestButtonAddsAndRemovesBeatmap() [Test] public void TestManageCollectionsFilterIsNotSelected() { + bool received = false; + addExpandHeaderStep(); AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List { "abc" })))); @@ -212,6 +213,12 @@ public void TestManageCollectionsFilterIsNotSelected() addExpandHeaderStep(); + AddStep("watch for filter requests", () => + { + received = false; + dropdown.ChildrenOfType().First().RequestFilter = () => received = true; + }); + AddStep("click manage collections filter", () => { int lastItemIndex = dropdown.ChildrenOfType().Single().Items.Count() - 1; @@ -220,6 +227,8 @@ public void TestManageCollectionsFilterIsNotSelected() }); AddAssert("collection filter still selected", () => dropdown.Current.Value.CollectionName == "1"); + + AddAssert("filter request not fired", () => !received); } private void writeAndRefresh(Action action) => Realm.Write(r => @@ -232,7 +241,7 @@ private void writeAndRefresh(Action action) => Realm.Write(r => private void assertCollectionHeaderDisplays(LocalisableString collectionName, bool shouldDisplay = true) => AddUntilStep($"collection dropdown header displays '{collectionName}'", - () => shouldDisplay == dropdown.ChildrenOfType().Any(h => h.ChildrenOfType().Any(t => t.Text == collectionName))); + () => shouldDisplay == dropdown.ChildrenOfType().Any(h => h.ChildrenOfType().Any(t => t.Text == collectionName))); private void assertFirstButtonIs(IconUsage icon) => AddUntilStep($"button is {icon.Icon.ToString()}", () => getAddOrRemoveButton(1).Icon.Equals(icon)); @@ -246,7 +255,7 @@ private IconButton getAddOrRemoveButton(int index) private void addExpandHeaderStep() => AddStep("expand header", () => { - InputManager.MoveMouseTo(dropdown.ChildrenOfType().Single()); + InputManager.MoveMouseTo(dropdown.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index c2277f2c7ce6..5eec60e9ec40 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -26,7 +26,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osuTK; using osuTK.Input; @@ -36,7 +36,7 @@ namespace osu.Game.Tests.Visual.UserInterface public partial class TestSceneDeleteLocalScore : OsuManualInputManagerTestScene { private readonly ContextMenuContainer contextMenuContainer; - private readonly BeatmapLeaderboard leaderboard; + private readonly BeatmapLeaderboardWedge leaderboard; private RulesetStore rulesets = null!; private BeatmapManager beatmapManager; @@ -46,9 +46,16 @@ public partial class TestSceneDeleteLocalScore : OsuManualInputManagerTestScene private BeatmapInfo beatmapInfo; + private LeaderboardManager leaderboardManager { get; set; } + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Cached(typeof(IDialogOverlay))] private readonly DialogOverlay dialogOverlay; + private IEnumerable scores => leaderboardManager.Scores.Value?.AllScores ?? Enumerable.Empty(); + public TestSceneDeleteLocalScore() { Children = new Drawable[] @@ -56,13 +63,11 @@ public TestSceneDeleteLocalScore() contextMenuContainer = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Child = leaderboard = new BeatmapLeaderboard + Child = leaderboard = new BeatmapLeaderboardWedge { Origin = Anchor.Centre, Anchor = Anchor.Centre, - Size = new Vector2(550f, 450f), - Scope = BeatmapLeaderboardScope.Local, - BeatmapInfo = TestResources.CreateTestBeatmapSetInfo().Beatmaps.First() + Size = new Vector2(0.6f), } }, dialogOverlay = new DialogOverlay() @@ -76,8 +81,11 @@ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnl dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get(), () => beatmapManager, LocalStorage, Realm, API)); + dependencies.Cache(leaderboardManager = new LeaderboardManager()); Dependencies.Cache(Realm); + Add(leaderboardManager); + return dependencies; } @@ -125,13 +133,13 @@ public void SetupSteps() }); AddStep("set up leaderboard", () => { - leaderboard.BeatmapInfo = beatmapInfo; - leaderboard.RefetchScores(); // Required in the case that the beatmap hasn't changed + Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo); + leaderboard.Show(); }); // Ensure the leaderboard items have finished showing up AddStep("finish transforms", () => leaderboard.FinishTransforms(true)); - AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType().Any()); + AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType().Any()); } [Test] @@ -140,7 +148,7 @@ public void TestDeleteViaRightClick() ScoreInfo scoreBeingDeleted = null; AddStep("open menu for top score", () => { - var leaderboardScore = leaderboard.ChildrenOfType().First(); + var leaderboardScore = leaderboard.ChildrenOfType().First(); scoreBeingDeleted = leaderboardScore.Score; @@ -167,8 +175,8 @@ public void TestDeleteViaRightClick() InputManager.PressButton(MouseButton.Left); }); - AddUntilStep("wait for fetch", () => leaderboard.Scores.Any()); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID)); + AddUntilStep("wait for fetch", () => scores.Any()); + AddUntilStep("score removed from leaderboard", () => scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID)); // "Clean up" AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); @@ -178,8 +186,8 @@ public void TestDeleteViaRightClick() public void TestDeleteViaDatabase() { AddStep("delete top score", () => scoreManager.Delete(importedScores[0])); - AddUntilStep("wait for fetch", () => leaderboard.Scores.Any()); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != importedScores[0].OnlineID)); + AddUntilStep("wait for fetch", () => scores.Any()); + AddUntilStep("score removed from leaderboard", () => scores.All(s => s.OnlineID != importedScores[0].OnlineID)); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingContainer.cs index 3f4f86e42444..db949c6754d9 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingContainer.cs @@ -4,12 +4,12 @@ #nullable disable using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; -using osu.Game.Overlays.Settings.Sections; using osuTK; namespace osu.Game.Tests.Visual.UserInterface @@ -19,9 +19,12 @@ public partial class TestSceneExpandingContainer : OsuManualInputManagerTestScen private TestExpandingContainer container; private SettingsToolboxGroup toolboxGroup; - private ExpandableSlider> slider1; + private ExpandableSlider slider1; private ExpandableSlider slider2; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [SetUp] public void SetUp() => Schedule(() => { @@ -36,7 +39,7 @@ public void SetUp() => Schedule(() => Width = 1, Children = new Drawable[] { - slider1 = new ExpandableSlider> + slider1 = new ExpandableSlider { Current = new BindableFloat { @@ -62,13 +65,13 @@ public void SetUp() => Schedule(() => slider1.Current.BindValueChanged(v => { - slider1.ExpandedLabelText = $"Slider One ({v.NewValue:0.##x})"; + slider1.ExpandedLabelText = "Slider One"; slider1.ContractedLabelText = $"S. 1. ({v.NewValue:0.##x})"; }, true); slider2.Current.BindValueChanged(v => { - slider2.ExpandedLabelText = $"Slider Two ({v.NewValue:N2})"; + slider2.ExpandedLabelText = "Slider Two"; slider2.ContractedLabelText = $"S. 2. ({v.NewValue:N2})"; }, true); }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs index dc51e5516ae2..c0e7a1761dfa 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs @@ -12,7 +12,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; @@ -23,17 +22,16 @@ using osu.Game.Overlays.FirstRunSetup; using osu.Game.Overlays.Notifications; using osu.Game.Screens; -using osu.Game.Screens.Footer; using osu.Game.Tests.Beatmaps; using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public partial class TestSceneFirstRunSetupOverlay : OsuManualInputManagerTestScene + public partial class TestSceneFirstRunSetupOverlay : ScreenTestScene { - private FirstRunSetupOverlay overlay; - private ScreenFooter footer; + private TestFirstRunSetupOverlayScreen screen = null!; + private FirstRunSetupOverlay overlay => screen.Overlay; private readonly Mock performer = new Mock(); @@ -53,8 +51,10 @@ private void load() } [SetUpSteps] - public void SetUpSteps() + public override void SetUpSteps() { + base.SetUpSteps(); + AddStep("setup dependencies", () => { performer.Reset(); @@ -67,16 +67,16 @@ public void SetUpSteps() .Callback((Notification n) => lastNotification = n); }); - createOverlay(); + AddStep("reset first run", () => LocalConfig.SetValue(OsuSetting.ShowFirstRunSetup, true)); - AddStep("show overlay", () => overlay.Show()); + createScreen(); } [Test] public void TestBasic() { AddAssert("overlay visible", () => overlay.State.Value == Visibility.Visible); - AddAssert("footer visible", () => footer.State.Value == Visibility.Visible); + AddAssert("footer visible", () => ScreenFooter.State.Value == Visibility.Visible); } [Test] @@ -92,7 +92,8 @@ public void TestDoesntOpenOnSecondRun() AddAssert("first run false", () => !LocalConfig.Get(OsuSetting.ShowFirstRunSetup)); - createOverlay(); + AddStep("exit screen", () => Stack.Exit()); + createScreen(); AddWaitStep("wait some", 5); @@ -146,7 +147,7 @@ public void TestBackButton(bool keyboard) if (keyboard) InputManager.Key(Key.Escape); else - footer.BackButton.TriggerClick(); + ScreenFooter.BackButton.TriggerClick(); } return overlay.CurrentScreen is ScreenWelcome; @@ -161,7 +162,7 @@ public void TestBackButton(bool keyboard) } else { - AddStep("press back button", () => footer.BackButton.TriggerClick()); + AddStep("press back button", () => ScreenFooter.BackButton.TriggerClick()); AddAssert("overlay dismissed", () => overlay.State.Value == Visibility.Hidden); } } @@ -204,25 +205,45 @@ public void TestResumeViaNotification() AddAssert("is resumed", () => overlay.CurrentScreen is ScreenUIScale); } - private void createOverlay() + private void createScreen() { - AddStep("add overlay", () => + AddStep("push screen", () => LoadScreen(screen = new TestFirstRunSetupOverlayScreen())); + AddUntilStep("wait until screen is loaded", () => screen.IsLoaded, () => Is.True); + } + + private partial class TestFirstRunSetupOverlayScreen : OsuScreen + { + public override bool ShowFooter => true; + + public FirstRunSetupOverlay Overlay = null!; + + [CanBeNull] + private IDisposable overlayRegistration; + + [CanBeNull] + [Resolved] + private IOverlayManager overlayManager { get; set; } + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + [BackgroundDependencyLoader] + private void load() { - var receptor = new ScreenFooter.BackReceptor(); - footer = new ScreenFooter(receptor); + LoadComponent(Overlay = new FirstRunSetupOverlay()); + } - Child = new DependencyProvidingContainer - { - RelativeSizeAxes = Axes.Both, - CachedDependencies = new[] { (typeof(ScreenFooter), (object)footer) }, - Children = new Drawable[] - { - receptor, - overlay = new FirstRunSetupOverlay(), - footer, - } - }; - }); + protected override void LoadComplete() + { + base.LoadComplete(); + overlayRegistration = overlayManager?.RegisterBlockingOverlay(Overlay); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + overlayRegistration?.Dispose(); + } } // interface mocks break hot reload, mocking this stub implementation instead works around it. diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs deleted file mode 100644 index b79ce6c75fe3..000000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.Select; -using osu.Game.Utils; - -namespace osu.Game.Tests.Visual.UserInterface -{ - public partial class TestSceneFooterButtonMods : OsuTestScene - { - private readonly TestFooterButtonMods footerButtonMods; - - public TestSceneFooterButtonMods() - { - Add(footerButtonMods = new TestFooterButtonMods()); - } - - [Test] - public void TestIncrementMultiplier() - { - var hiddenMod = new Mod[] { new OsuModHidden() }; - AddStep(@"Add Hidden", () => changeMods(hiddenMod)); - AddAssert(@"Check Hidden multiplier", () => assertModsMultiplier(hiddenMod)); - - var hardRockMod = new Mod[] { new OsuModHardRock() }; - AddStep(@"Add HardRock", () => changeMods(hardRockMod)); - AddAssert(@"Check HardRock multiplier", () => assertModsMultiplier(hardRockMod)); - - var doubleTimeMod = new Mod[] { new OsuModDoubleTime() }; - AddStep(@"Add DoubleTime", () => changeMods(doubleTimeMod)); - AddAssert(@"Check DoubleTime multiplier", () => assertModsMultiplier(doubleTimeMod)); - - var multipleIncrementMods = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModHardRock() }; - AddStep(@"Add multiple Mods", () => changeMods(multipleIncrementMods)); - AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(multipleIncrementMods)); - } - - [Test] - public void TestDecrementMultiplier() - { - var easyMod = new Mod[] { new OsuModEasy() }; - AddStep(@"Add Easy", () => changeMods(easyMod)); - AddAssert(@"Check Easy multiplier", () => assertModsMultiplier(easyMod)); - - var noFailMod = new Mod[] { new OsuModNoFail() }; - AddStep(@"Add NoFail", () => changeMods(noFailMod)); - AddAssert(@"Check NoFail multiplier", () => assertModsMultiplier(noFailMod)); - - var multipleDecrementMods = new Mod[] { new OsuModEasy(), new OsuModNoFail() }; - AddStep(@"Add Multiple Mods", () => changeMods(multipleDecrementMods)); - AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(multipleDecrementMods)); - } - - [Test] - public void TestClearMultiplier() - { - var multipleMods = new Mod[] { new OsuModDoubleTime(), new OsuModFlashlight() }; - AddStep(@"Add mods", () => changeMods(multipleMods)); - AddStep(@"Clear selected mod", () => changeMods(Array.Empty())); - AddAssert(@"Check empty multiplier", () => assertModsMultiplier(Array.Empty())); - } - - [Test] - public void TestUnrankedBadge() - { - AddStep(@"Add unranked mod", () => changeMods(new[] { new OsuModDeflate() })); - AddAssert("Unranked badge shown", () => footerButtonMods.UnrankedBadge.Alpha == 1); - AddStep(@"Clear selected mod", () => changeMods(Array.Empty())); - AddAssert("Unranked badge not shown", () => footerButtonMods.UnrankedBadge.Alpha == 0); - } - - private void changeMods(IReadOnlyList mods) - { - footerButtonMods.Current.Value = mods; - } - - private bool assertModsMultiplier(IEnumerable mods) - { - double multiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); - string expectedValue = multiplier == 1 ? string.Empty : ModUtils.FormatScoreMultiplier(multiplier).ToString(); - - return expectedValue == footerButtonMods.MultiplierText.Current.Value; - } - - private partial class TestFooterButtonMods : FooterButtonMods - { - public new OsuSpriteText MultiplierText => base.MultiplierText; - public new Drawable UnrankedBadge => base.UnrankedBadge; - } - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormButton.cs new file mode 100644 index 000000000000..a22607e78160 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormButton.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneFormButton : ThemeComparisonTestScene + { + public TestSceneFormButton() + : base(false) + { + } + + protected override Drawable CreateContent() => new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new BackgroundBox + { + RelativeSizeAxes = Axes.Both, + }, + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new FormButton + { + Caption = "Button with default style", + Action = () => { }, + }, + new FormButton + { + Caption = "Button with default style", + Enabled = { Value = false }, + }, + new FormButton + { + Caption = "Button with custom style", + BackgroundColour = new OsuColour().DangerousButtonColour, + ButtonIcon = FontAwesome.Solid.Hamburger, + Action = () => { }, + }, + new FormButton + { + Caption = "Button with custom style", + BackgroundColour = new OsuColour().DangerousButtonColour, + ButtonIcon = FontAwesome.Solid.Hamburger, + Enabled = { Value = false }, + }, + new FormButton + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + BackgroundColour = new OsuColour().Blue3, + ButtonIcon = FontAwesome.Solid.Book, + Action = () => { }, + }, + new FormButton + { + Caption = "Button with text inside", + ButtonText = "Text in button", + Action = () => { }, + }, + new FormButton + { + Caption = "Button with text inside", + ButtonText = "Text in button", + Enabled = { Value = false }, + }, + new FormButton + { + Caption = "Button with text inside", + ButtonText = "Text in button", + BackgroundColour = new OsuColour().DangerousButtonColour, + Action = () => { }, + }, + new FormButton + { + Caption = "Button with text inside", + ButtonText = "Text in button", + BackgroundColour = new OsuColour().DangerousButtonColour, + Enabled = { Value = false }, + }, + new FormButton + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor", + ButtonText = "Text in button", + BackgroundColour = new OsuColour().Blue3, + Action = () => { }, + }, + new FormButton + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor", + ButtonText = "Text in button", + BackgroundColour = new OsuColour().Blue3, + Enabled = { Value = false }, + }, + }, + }, + }, + } + } + }; + + private partial class BackgroundBox : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Background4; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs index 2003f5de8358..22b3753320ca 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs @@ -1,15 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.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.Game.Beatmaps; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Overlays; using osu.Game.Screens.Edit.Setup; using osuTK; @@ -25,109 +28,264 @@ public TestSceneFormControls() protected override Drawable CreateContent() => new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Child = new PopoverContainer + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Child = new OsuScrollContainer + new BackgroundBox { RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer + }, + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuScrollContainer { - AutoSizeAxes = Axes.Y, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 400, - Direction = FillDirection.Vertical, - Spacing = new Vector2(5), - Padding = new MarginPadding(10), - Children = new Drawable[] + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer { - new FormTextBox - { - Caption = "Artist", - HintText = "Poot artist here!", - PlaceholderText = "Here is an artist", - TabbableContentContainer = this, - }, - new FormTextBox - { - Caption = "Artist", - HintText = "Poot artist here!", - PlaceholderText = "Here is an artist", - Current = { Disabled = true }, - TabbableContentContainer = this, - }, - new FormNumberBox(allowDecimals: true) - { - Caption = "Number", - HintText = "Insert your favourite number", - PlaceholderText = "Mine is 42!", - TabbableContentContainer = this, - }, - new FormCheckBox - { - Caption = EditorSetupStrings.LetterboxDuringBreaks, - HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - }, - new FormCheckBox - { - Caption = EditorSetupStrings.LetterboxDuringBreaks, - HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - Current = { Disabled = true }, - }, - new FormSliderBar + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Horizontal, + Children = new[] { - Caption = "Slider", - Current = new BindableFloat + new FillFlowContainer { - MinValue = 0, - MaxValue = 10, - Value = 5, - Precision = 0.1f, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new FormTextBox + { + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + TabbableContentContainer = this, + }, + new FormTextBox + { + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + Current = { Disabled = true }, + TabbableContentContainer = this, + }, + new FormNumberBox(allowDecimals: true) + { + Caption = "Number", + HintText = "Insert your favourite number", + PlaceholderText = "Mine is 42!", + TabbableContentContainer = this, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + Current = { Disabled = true }, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + Current = { Value = true, Disabled = true }, + }, + new FormSliderBar + { + Caption = "Slider", + HintText = "Slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + TabbableContentContainer = this, + }, + new FormSliderBar + { + Caption = "Slider", + HintText = "Slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + Disabled = true, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + new FormSliderBar + { + Caption = "Slider (percentage)", + HintText = "Percentage slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 1, + Value = 0.2f, + Precision = 0.0001f, + }, + DisplayAsPercentage = true, + TabbableContentContainer = this, + }, + new FormSliderBar + { + Caption = "Slider (custom)", + HintText = "Custom slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 1, + Value = 0.2f, + Precision = 0.0001f, + }, + LabelFormat = v => $"{v * 100:0.00} funometer", + TooltipFormat = v => $"This setting has the value set to {v * 100:0.00} funometer.", + TabbableContentContainer = this, + }, + new FormSliderBar + { + Caption = "Slider (custom)", + HintText = "Custom slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 1, + Value = 0.2f, + Precision = 0.0001f, + Disabled = true, + }, + TransferValueOnCommit = true, + LabelFormat = v => $"{v * 100:0.00} funometer", + TooltipFormat = v => $"This setting has the value set to {v * 100:0.00} funometer.", + TabbableContentContainer = this, + }, + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + }, + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + Current = { Disabled = true }, + }, + new FormFileSelector + { + Caption = "File selector", + PlaceholderText = "Select a file", + }, + new FormBeatmapFileSelector(true) + { + Caption = "File selector with intermediate choice dialog", + PlaceholderText = "Select a file", + }, + new FormColourPalette + { + Caption = "Combo colours", + Colours = + { + Colour4.Red, + Colour4.Green, + Colour4.Blue, + Colour4.Yellow, + } + }, + new FormButton + { + Caption = "No text in button", + Action = () => { }, + }, + }, }, - TabbableContentContainer = this, - }, - new FormEnumDropdown - { - Caption = EditorSetupStrings.EnableCountdown, - HintText = EditorSetupStrings.CountdownDescription, - }, - new FormFileSelector - { - Caption = "File selector", - PlaceholderText = "Select a file", - }, - new FormBeatmapFileSelector(true) - { - Caption = "File selector with intermediate choice dialog", - PlaceholderText = "Select a file", - }, - new FormColourPalette - { - Caption = "Combo colours", - Colours = + new FillFlowContainer { - Colour4.Red, - Colour4.Green, - Colour4.Blue, - Colour4.Yellow, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new FormNumberBox(allowDecimals: true) + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = "Insert your favourite number", + PlaceholderText = "Mine is 42!", + TabbableContentContainer = this, + }, + new FormCheckBox + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + }, + new FormSliderBar + { + Caption = "Lorem ipsum dolor sit amet, conse adipiscing elit, sed do eiusmod", + HintText = "Slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + TabbableContentContainer = this, + }, + new FormEnumDropdown + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = EditorSetupStrings.CountdownDescription, + }, + new FormFileSelector + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = EditorSetupStrings.CountdownDescription, + PlaceholderText = "Select a file", + }, + new FormColourPalette + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = EditorSetupStrings.CountdownDescription, + Colours = + { + Colour4.Red, + Colour4.Green, + Colour4.Blue, + Colour4.Yellow, + } + }, + }, } }, - new FormButton - { - Caption = "No text in button", - Action = () => { }, - }, - new FormButton - { - Caption = "Text in button which is pretty long and is very likely to wrap", - ButtonText = "Foo the bar", - Action = () => { }, - }, }, }, - }, + } } }; + + private partial class BackgroundBox : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Background4; + } + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormDropdown.cs new file mode 100644 index 000000000000..69d6057b9a68 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormDropdown.cs @@ -0,0 +1,104 @@ +// 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.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneFormDropdown : ThemeComparisonTestScene + { + public TestSceneFormDropdown() + : base(false) + { + } + + protected override Drawable CreateContent() => new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new BackgroundBox + { + RelativeSizeAxes = Axes.Both, + }, + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + }, + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + Current = { Disabled = true }, + }, + new FormDropdown + { + Caption = "Custom dropdown", + HintText = "Custom dropdown hint", + Items = new[] + { + "A verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "B verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "C verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "D verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + }, + }, + new FormDropdown + { + Caption = "Custom dropdown", + HintText = "Custom dropdown hint", + AlwaysShowSearchBar = true, + Items = new[] + { + "A verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "B verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "C verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "D verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + }, + }, + }, + }, + }, + } + } + }; + + private partial class BackgroundBox : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Background4; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs index 97835a993d60..e7019aabe199 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs @@ -1,20 +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 System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public partial class TestSceneFormSliderBar : OsuTestScene + public partial class TestSceneFormSliderBar : OsuManualInputManagerTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); @@ -59,5 +63,293 @@ public void TestTransferValueOnCommit() slider.TransferValueOnCommit = b; }); } + + [TestCase(false)] + [TestCase(true)] + public void TestNubDoubleClickRevertToDefault(bool transferValueOnCommit) + { + OsuSpriteText text; + FormSliderBar slider = null!; + + AddStep("create content", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + text = new OsuSpriteText(), + slider = new FormSliderBar + { + Caption = "Slider", + TransferValueOnCommit = transferValueOnCommit, + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + Default = 5f, + } + }, + } + }; + slider.Current.BindValueChanged(_ => text.Text = $"Current value is: {slider.Current.Value}", true); + }); + AddStep("set slider to 1", () => slider.Current.Value = 1); + + AddStep("move mouse to nub", () => InputManager.MoveMouseTo(slider.ChildrenOfType().Single())); + + AddStep("double click nub", () => + { + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("slider is default", () => slider.Current.IsDefault); + } + + [Test] + public void TestDisabled() + { + FormSliderBar slider = null!; + + AddStep("create content", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + slider = new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + Default = 5f, + } + }, + } + }; + }); + AddStep("set slider to 1", () => slider.Current.Value = 1); + AddStep("disable slider", () => slider.Current.Disabled = true); + + AddStep("move mouse to nub", () => InputManager.MoveMouseTo(slider.ChildrenOfType().Single())); + + AddStep("double click nub", () => + { + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("slider is still at 1", () => slider.Current.Value, () => Is.EqualTo(1)); + + AddStep("click on textbox part", () => + { + InputManager.MoveMouseTo(slider.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("no text selected", () => slider.ChildrenOfType().Single().SelectedText, () => Is.Empty); + AddStep("attempt to input text", () => + { + InputManager.Key(Key.Number4); + InputManager.Key(Key.Enter); + }); + AddAssert("slider is still at 1", () => slider.Current.Value, () => Is.EqualTo(1)); + + AddStep("re-enable slider", () => slider.Current.Disabled = false); + + AddStep("move mouse to nub", () => InputManager.MoveMouseTo(slider.ChildrenOfType().Single())); + + AddStep("double click nub", () => + { + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + AddAssert("slider is at 5", () => slider.Current.Value, () => Is.EqualTo(5)); + } + + [Test] + public void TestDisabledImmediately() + { + FormSliderBar slider = null!; + + AddStep("create content", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + slider = new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + Default = 5f, + Disabled = true, + }, + TransferValueOnCommit = true, + }, + } + }; + }); + + AddStep("click on textbox part", () => + { + InputManager.MoveMouseTo(slider.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("no text selected", () => slider.ChildrenOfType().Single().SelectedText, () => Is.Empty); + } + + [Test] + public void TestDisplayAsPercentageFloat() + { + OsuSpriteText text; + FormSliderBar slider = null!; + + AddStep("create content", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + text = new OsuSpriteText(), + slider = new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 1, + Precision = 0.01f, + Default = 0.5f, + Value = 0.5f, + }, + DisplayAsPercentage = true, + }, + } + }; + slider.Current.BindValueChanged(_ => text.Text = $"Current value is: {slider.Current.Value}", true); + }); + + AddStep("click on textbox part", () => + { + InputManager.MoveMouseTo(slider.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("text selected", () => slider.ChildrenOfType().Single().SelectedText, () => Is.EqualTo("50")); + AddStep("input 9%", () => + { + slider.ChildrenOfType().Single().Text = "9"; + InputManager.Key(Key.Enter); + }); + AddAssert("slider is at 0.09", () => slider.Current.Value, () => Is.EqualTo(0.09f)); + + AddStep("start dragging nub", () => + { + InputManager.MoveMouseTo(slider.ChildrenOfType.InnerSliderNub>().Single()); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("drag nub to 50%", () => + { + var innerSlider = slider.ChildrenOfType.InnerSlider>().Single(); + InputManager.MoveMouseTo((innerSlider.ScreenSpaceDrawQuad.TopLeft + innerSlider.ScreenSpaceDrawQuad.TopRight) / 2); + InputManager.ReleaseButton(MouseButton.Left); + }); + AddAssert("slider is at ~0.5", () => slider.Current.Value, () => Is.EqualTo(0.5).Within(0.01f)); + } + + [Test] + public void TestDisplayAsPercentageInt() + { + OsuSpriteText text; + FormSliderBar slider = null!; + + AddStep("create content", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + text = new OsuSpriteText(), + slider = new FormSliderBar + { + Caption = "Slider", + Current = new BindableInt + { + MinValue = 0, + MaxValue = 100, + Precision = 1, + Default = 50, + Value = 50, + }, + DisplayAsPercentage = true, + }, + } + }; + slider.Current.BindValueChanged(_ => text.Text = $"Current value is: {slider.Current.Value}", true); + }); + + AddStep("click on textbox part", () => + { + InputManager.MoveMouseTo(slider.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("text selected", () => slider.ChildrenOfType().Single().SelectedText, () => Is.EqualTo("50")); + AddStep("input 9%", () => + { + slider.ChildrenOfType().Single().Text = "9"; + InputManager.Key(Key.Enter); + }); + AddAssert("slider is at 9", () => slider.Current.Value, () => Is.EqualTo(9)); + + AddStep("start dragging nub", () => + { + InputManager.MoveMouseTo(slider.ChildrenOfType.InnerSliderNub>().Single()); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("drag nub to 50%", () => + { + var innerSlider = slider.ChildrenOfType.InnerSlider>().Single(); + InputManager.MoveMouseTo((innerSlider.ScreenSpaceDrawQuad.TopLeft + innerSlider.ScreenSpaceDrawQuad.TopRight) / 2); + InputManager.ReleaseButton(MouseButton.Left); + }); + AddAssert("slider is at ~50", () => slider.Current.Value, () => Is.EqualTo(50).Within(1)); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToExitGameOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToExitGameOverlay.cs index df423268b609..fdd1a1172297 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToExitGameOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToExitGameOverlay.cs @@ -40,20 +40,20 @@ public TestSceneHoldToExitGameOverlay() firedText }; - AddStep("start confirming", () => overlay.Begin()); - AddStep("abort confirming", () => overlay.Abort()); + AddStep("start confirming", overlay.Begin); + AddStep("abort confirming", overlay.Abort); AddAssert("ensure not fired internally", () => !overlay.Fired); AddAssert("ensure aborted", () => !fired); - AddStep("start confirming", () => overlay.Begin()); + AddStep("start confirming", overlay.Begin); AddUntilStep("wait until confirmed", () => fired); AddAssert("ensure fired internally", () => overlay.Fired); - AddStep("abort after fire", () => overlay.Abort()); + AddStep("abort after fire", overlay.Abort); AddAssert("ensure not fired internally", () => !overlay.Fired); - AddStep("start confirming", () => overlay.Begin()); + AddStep("start confirming", overlay.Begin); AddUntilStep("wait until fired again", () => overlay.Fired); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs index 300b451cf500..941f6667e2c4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs @@ -11,7 +11,7 @@ public partial class TestSceneLabelledDropdown : OsuTestScene { [Test] public void TestLabelledDropdown() - => AddStep(@"create dropdown", () => Child = new LabelledDropdown + => AddStep(@"create dropdown", () => Child = new LabelledDropdown(true) { Label = @"Countdown speed", Items = new[] @@ -25,7 +25,7 @@ public void TestLabelledDropdown() [Test] public void TestLabelledEnumDropdown() - => AddStep(@"create dropdown", () => Child = new LabelledEnumDropdown + => AddStep(@"create dropdown", () => Child = new LabelledEnumDropdown(true) { Label = @"Beatmap status", Description = @"This is a description" diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs index bec517af2c11..24ef51806a21 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs @@ -2,14 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneLabelledSwitchButton : OsuTestScene { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + [TestCase(false)] [TestCase(true)] public void TestSwitchButton(bool hasDescription) => createSwitchButton(hasDescription); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs index 66bf870f90b2..2fb8d11cd32b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs @@ -7,21 +7,27 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public partial class TestSceneLoadingLayer : OsuTestScene + public partial class TestSceneLoadingLayer : OsuManualInputManagerTestScene { private TestLoadingLayer overlay; private Container content; + private PressableButton pressableButton; + [SetUp] public void SetUp() => Schedule(() => { @@ -51,10 +57,9 @@ public void SetUp() => Schedule(() => { new OsuSpriteText { Text = "Sample content" }, new RoundedButton { Text = "can't puush me", Width = 200, }, - new RoundedButton { Text = "puush me", Width = 200, Action = () => { } }, + pressableButton = new PressableButton { Text = "puush me", Width = 200 }, } }, - overlay = new TestLoadingLayer(true), } }, }; @@ -63,20 +68,62 @@ public void SetUp() => Schedule(() => [Test] public void TestShowHide() { + AddStep("create loading layer", () => content.Add(overlay = new TestLoadingLayer(true))); + AddAssert("not visible", () => !overlay.IsPresent); AddStep("show", () => overlay.Show()); - AddUntilStep("wait for content dim", () => overlay.Alpha > 0); AddStep("hide", () => overlay.Hide()); - AddUntilStep("wait for content restore", () => Precision.AlmostEquals(overlay.Alpha, 0)); } + [TestCase(true)] + [TestCase(false)] + public void TestBlockPositional(bool blockInput) + { + AddStep("create loading layer", () => content.Add(overlay = new TestLoadingLayer(true) { BlockPositionalInput = blockInput })); + AddStep("show", () => overlay.Show()); + + AddStep("click button", () => + { + InputManager.MoveMouseTo(pressableButton); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("check pressed", () => pressableButton.Pressed, () => Is.EqualTo(!blockInput)); + } + + [TestCase(true)] + [TestCase(false)] + public void TestBlockNonPositional(bool blockKeyboardInput) + { + AddStep("create loading layer", () => content.Add(overlay = new TestLoadingLayer(true) { BlockNonPositionalInput = blockKeyboardInput })); + AddStep("show", () => overlay.Show()); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddAssert("check pressed", () => pressableButton.Pressed, () => Is.EqualTo(!blockKeyboardInput)); + } + + [TestCase(true)] + [TestCase(false)] + public void TestBlockNonPositionalGlobalAction(bool blockKeyboardInput) + { + AddStep("create loading layer", () => content.Add(overlay = new TestLoadingLayer(true) { BlockNonPositionalInput = blockKeyboardInput })); + AddStep("show", () => overlay.Show()); + + AddStep("press enter", () => InputManager.Key(Key.F8)); + + AddAssert("check pressed", () => pressableButton.Pressed, () => Is.EqualTo(!blockKeyboardInput)); + } + [Test] public void TestLargeArea() { + AddStep("create loading layer", () => content.Add(overlay = new TestLoadingLayer(true))); + AddStep("show", () => { content.RelativeSizeAxes = Axes.Both; @@ -88,6 +135,42 @@ public void TestLargeArea() AddStep("hide", () => overlay.Hide()); } + public partial class PressableButton : RoundedButton, IKeyBindingHandler + { + public PressableButton() + { + Action = () => Pressed = true; + } + + public bool Pressed { get; private set; } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key == Key.Enter) + { + Pressed = true; + return true; + } + + return base.OnKeyDown(e); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.ToggleChat) + { + Pressed = true; + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + private partial class TestLoadingLayer : LoadingLayer { public TestLoadingLayer(bool dimBackground = false, bool withBox = true) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs index c202442f9cfb..cc2817cecf0f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs @@ -80,6 +80,35 @@ public void SetUpSteps() }); } + [Test] + public void TestNumericHotkeys() + { + AddStep("set osu! ruleset", () => Ruleset.Value = rulesets.GetRuleset(0)); + AddStep("create content", () => Child = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddUntilStep("3 panels visible", () => this.ChildrenOfType().Count() == 3); + + AddStep("select first preset", () => InputManager.Key(Key.Number1)); + AddAssert("first panel selected", () => this.ChildrenOfType().ElementAt(0).Active.Value); + + AddAssert("selected mods match correct preset", () => SelectedMods.Value, () => Is.EquivalentTo(createTestPresets().ElementAt(1).Mods)); + + AddStep("select third preset", () => InputManager.Key(Key.Number3)); + AddAssert("first panel not selected", () => !this.ChildrenOfType().ElementAt(0).Active.Value); + AddAssert("third panel selected", () => this.ChildrenOfType().ElementAt(2).Active.Value); + + AddAssert("selected mods match correct preset", () => SelectedMods.Value, () => Is.EquivalentTo(createTestPresets().ElementAt(2).Mods)); + + AddStep("deselect third preset", () => InputManager.Key(Key.Number3)); + AddAssert("third panel not selected", () => !this.ChildrenOfType().ElementAt(2).Active.Value); + + AddAssert("no selected mods", () => SelectedMods.Value.Count == 0); + } + [Test] public void TestBasicOperation() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 6127be481c1f..b4f9365c8f5e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Localisation; +using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Configuration; @@ -25,6 +26,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Screens; using osu.Game.Screens.Footer; using osu.Game.Tests.Mods; using osuTK; @@ -33,17 +35,19 @@ namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public partial class TestSceneModSelectOverlay : OsuManualInputManagerTestScene + public partial class TestSceneModSelectOverlay : ScreenTestScene { protected override bool UseFreshStoragePerRun => true; private RulesetStore rulesetStore = null!; - private TestModSelectOverlay modSelectOverlay = null!; + private TestModSelectOverlayScreen screen = null!; [Resolved] private OsuConfigManager configManager { get; set; } = null!; + private ModSelectOverlay modSelectOverlay => screen.Overlay; + [BackgroundDependencyLoader] private void load() { @@ -52,9 +56,10 @@ private void load() } [SetUpSteps] - public void SetUpSteps() + public override void SetUpSteps() { - AddStep("clear contents", Clear); + base.SetUpSteps(); + AddStep("reset ruleset", () => Ruleset.Value = rulesetStore.GetRuleset(0)); AddStep("reset mods", () => SelectedMods.SetDefault()); AddStep("reset config", () => configManager.SetValue(OsuSetting.ModSelectTextSearchStartsActive, true)); @@ -97,29 +102,8 @@ public void SetUpSteps() private void createScreen() { - AddStep("create screen", () => - { - var receptor = new ScreenFooter.BackReceptor(); - var footer = new ScreenFooter(receptor); - - Child = new DependencyProvidingContainer - { - RelativeSizeAxes = Axes.Both, - CachedDependencies = new[] { (typeof(ScreenFooter), (object)footer) }, - Children = new Drawable[] - { - receptor, - modSelectOverlay = new TestModSelectOverlay - { - RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible }, - Beatmap = { Value = Beatmap.Value }, - SelectedMods = { BindTarget = SelectedMods }, - }, - footer, - } - }; - }); + AddStep("create screen", () => LoadScreen(screen = new TestModSelectOverlayScreen { SelectedMods = { BindTarget = SelectedMods } })); + AddUntilStep("wait until screen is loaded", () => screen.IsLoaded, () => Is.True); waitForColumnLoad(); } @@ -306,29 +290,30 @@ public void TestDismissCustomisationWhenHidingOverlay() [Test] public void TestSettingsNotCrossPolluting() { + TestScreenWithTwoOverlays screenWithTwoOverlays = null!; Bindable> selectedMods2 = null!; - ModSelectOverlay modSelectOverlay2 = null!; - createScreen(); - AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); + AddStep("push screen", () => + { + selectedMods2 = new Bindable>(new Mod[] { new OsuModDifficultyAdjust() }); - AddStep("set setting", () => modSelectOverlay.ChildrenOfType>().First().Current.Value = 8); + LoadScreen(screen = screenWithTwoOverlays = new TestScreenWithTwoOverlays + { + SelectedMods = { BindTarget = SelectedMods }, + SelectedMods2 = { BindTarget = selectedMods2 }, + }); + }); + AddStep("wait until screen is loaded", () => screenWithTwoOverlays.IsCurrentScreen()); + waitForColumnLoad(); - AddAssert("ensure setting is propagated", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); - AddStep("create second bindable", () => selectedMods2 = new Bindable>(new Mod[] { new OsuModDifficultyAdjust() })); + AddStep("set setting", () => screenWithTwoOverlays.Overlay.ChildrenOfType>().First().Current.Value = 8); - AddStep("create second overlay", () => - { - Add(modSelectOverlay2 = new UserModSelectOverlay().With(d => - { - d.Origin = Anchor.TopCentre; - d.Anchor = Anchor.TopCentre; - d.SelectedMods.BindTarget = selectedMods2; - })); - }); + AddAssert("ensure setting is propagated", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); - AddStep("show", () => modSelectOverlay2.Show()); + AddStep("hide first overlay", () => screenWithTwoOverlays.Overlay.Hide()); + AddStep("show second overlay", () => screenWithTwoOverlays.SecondOverlay.Show()); AddAssert("ensure first is unchanged", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); AddAssert("ensure second is default", () => selectedMods2.Value.OfType().Single().CircleSize.Value == null); @@ -481,6 +466,7 @@ public void TestSettingsAreRetainedOnReload() AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } }); AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01); + AddStep("exit screen", () => Stack.Exit()); createScreen(); AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01); } @@ -712,6 +698,35 @@ public void TestDeselectAllViaKey_WithSearchApplied() AddUntilStep("all mods deselected", () => !SelectedMods.Value.Any()); } + [Test] + public void TestTouchDeviceDoesNotInterfereWithDeselectAll() + { + createScreen(); + changeRuleset(0); + + AddAssert("deselect all button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("select TD", () => SelectedMods.Value = new Mod[] { new OsuModTouchDevice() }); + AddAssert("deselect all button still disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("click deselect all button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("touch mod still present", () => SelectedMods.Value, () => Is.EqualTo(new Mod[] { new OsuModTouchDevice() })); + + AddStep("select NC + TD", () => SelectedMods.Value = new Mod[] { new OsuModTouchDevice(), new OsuModNightcore() }); + AddStep("click deselect all button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("touch mod still present", () => SelectedMods.Value, () => Is.EqualTo(new Mod[] { new OsuModTouchDevice() })); + } + [Test] public void TestDeselectAllViaButton() { @@ -797,16 +812,11 @@ public void TestCloseViaToggleModSelectionBinding() [Test] public void TestColumnHidingOnIsValidChange() { - AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay - { - RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible }, - SelectedMods = { BindTarget = SelectedMods }, - IsValidMod = mod => mod.Type == ModType.DifficultyIncrease || mod.Type == ModType.Conversion - }); - waitForColumnLoad(); + createScreen(); changeRuleset(0); + AddStep("set filter for 2 columns", () => modSelectOverlay.IsValidMod = mod => mod.Type is ModType.DifficultyIncrease or ModType.Conversion); + AddAssert("two columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 2); AddStep("unset filter", () => modSelectOverlay.IsValidMod = _ => true); @@ -816,9 +826,7 @@ public void TestColumnHidingOnIsValidChange() AddAssert("no columns visible", () => this.ChildrenOfType().All(col => !col.IsPresent)); AddStep("hide", () => modSelectOverlay.Hide()); - AddStep("set filter for 3 columns", () => modSelectOverlay.IsValidMod = mod => mod.Type == ModType.DifficultyReduction - || mod.Type == ModType.Automation - || mod.Type == ModType.Conversion); + AddStep("set filter for 3 columns", () => modSelectOverlay.IsValidMod = mod => mod.Type is ModType.DifficultyReduction or ModType.Automation or ModType.Conversion); AddStep("show", () => modSelectOverlay.Show()); AddUntilStep("3 columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 3); @@ -830,13 +838,7 @@ public void TestColumnHidingOnIsValidChange() [Test] public void TestColumnHidingOnTextFilterChange() { - AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay - { - RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible }, - SelectedMods = { BindTarget = SelectedMods } - }); - waitForColumnLoad(); + createScreen(); changeRuleset(0); AddAssert("all columns visible", () => this.ChildrenOfType().All(col => col.IsPresent)); @@ -854,13 +856,7 @@ public void TestColumnHidingOnTextFilterChange() [Test] public void TestHidingOverlayClearsTextSearch() { - AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay - { - RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible }, - SelectedMods = { BindTarget = SelectedMods } - }); - waitForColumnLoad(); + createScreen(); changeRuleset(0); AddAssert("all columns visible", () => this.ChildrenOfType().All(col => col.IsPresent)); @@ -1019,8 +1015,8 @@ public void TestActiveStatesRefreshedOnPanelsCreated() { selectedMods = new Bindable>([]); - modSelectOverlay.SelectedMods.UnbindFrom(SelectedMods); - modSelectOverlay.SelectedMods.BindTo(selectedMods); + screen.SelectedMods.UnbindFrom(SelectedMods); + screen.SelectedMods.BindTo(selectedMods); }); AddStep("activate PF", () => selectedMods.Value = [new OsuModPerfect()]); @@ -1066,11 +1062,79 @@ protected override void Dispose(bool isDisposing) rulesetStore.Dispose(); } - private partial class TestModSelectOverlay : UserModSelectOverlay + private partial class TestModSelectOverlayScreen : OsuScreen { - public TestModSelectOverlay() + public readonly Bindable> SelectedMods = new Bindable>(); + + public override bool ShowFooter => true; + + public ModSelectOverlay Overlay = null!; + + private IDisposable? firstOverlayRegistration; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + [Resolved] + protected IOverlayManager? OverlayManager { get; private set; } + + [BackgroundDependencyLoader] + private void load() + { + LoadComponent(Overlay = new UserModSelectOverlay + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Beatmap = { Value = Beatmap.Value }, + SelectedMods = { BindTarget = SelectedMods }, + ShowPresets = true, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + firstOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(Overlay); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + firstOverlayRegistration?.Dispose(); + } + } + + private partial class TestScreenWithTwoOverlays : TestModSelectOverlayScreen + { + public readonly Bindable> SelectedMods2 = new Bindable>([]); + + public ModSelectOverlay SecondOverlay = null!; + + private IDisposable? secondOverlayRegistration; + + [BackgroundDependencyLoader] + private void load() + { + LoadComponent(SecondOverlay = new UserModSelectOverlay + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + SelectedMods = { BindTarget = SelectedMods2 }, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + secondOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(SecondOverlay); + } + + protected override void Dispose(bool isDisposing) { - ShowPresets = true; + base.Dispose(isDisposing); + secondOverlayRegistration?.Dispose(); } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs index 4bd3a883f1ab..34795f3b1f84 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs @@ -96,7 +96,7 @@ private enum EnumSetting private partial class EmptyToast : Toast { public EmptyToast() - : base("", "", "") + : base("", "") { } } @@ -104,8 +104,9 @@ public EmptyToast() private partial class LengthyToast : Toast { public LengthyToast() - : base("Toast with a very very very long text", "A very very very very very very long text also", "A very very very very very long shortcut") + : base("Toast with a very very very long text", "A very very very very very very long text also") { + ExtraText = "A very very very very very long shortcut"; } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs new file mode 100644 index 000000000000..97b72924971f --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs @@ -0,0 +1,365 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Screens; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Select; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneScreenFooter : ScreenTestScene + { + [Test] + public void TestButtonsIn() + { + AddStep("push empty screen", () => LoadScreen(new TestScreen())); + AddStep("push screen", () => LoadScreen(new TestScreen + { + CreateButtons = () => new[] + { + new ScreenFooterButton { Text = "Button 1", Action = () => { } }, + new ScreenFooterButton { Text = "Button 2", Action = () => { } }, + new ScreenFooterButton { Text = "Button 3", Action = () => { } }, + }, + })); + } + + [Test] + public void TestButtonsOut() + { + AddStep("push empty screen", () => LoadScreen(new TestScreen())); + AddStep("push screen", () => LoadScreen(new TestScreen + { + CreateButtons = () => new[] + { + new ScreenFooterButton { Text = "Button 1", Action = () => { } }, + new ScreenFooterButton { Text = "Button 2", Action = () => { } }, + new ScreenFooterButton { Text = "Button 3", Action = () => { } }, + }, + })); + AddStep("exit screen", () => Stack.Exit()); + } + + [Test] + public void TestReplaceButtons() + { + AddStep("push first screen", () => LoadScreen(new TestScreen + { + CreateButtons = () => new[] + { + new ScreenFooterButton { Text = "Button 1", Action = () => { } }, + new ScreenFooterButton { Text = "Button 2", Action = () => { } }, + new ScreenFooterButton { Text = "Button 3", Action = () => { } }, + }, + })); + AddStep("push second screen", () => LoadScreen(new TestScreen + { + CreateButtons = () => new[] + { + new ScreenFooterButton { Text = "Button 4", Action = () => { } }, + new ScreenFooterButton { Text = "Button 5", Action = () => { } }, + new ScreenFooterButton { Text = "Button 6", Action = () => { } }, + }, + })); + } + + [Test] + public void TestFooterVisibility() + { + TestScreen screen = null!; + TestScreen screenWithoutFooter = null!; + + AddAssert("footer hidden", () => ScreenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("push screen", () => LoadScreen(screen = new TestScreen + { + CreateButtons = () => new[] + { + new ScreenFooterButton { Text = "Button 1", Action = () => { } }, + new ScreenFooterButton { Text = "Button 2", Action = () => { } }, + new ScreenFooterButton { Text = "Button 3", Action = () => { } }, + }, + })); + AddUntilStep("wait until screen is loaded", () => screen.IsCurrentScreen(), () => Is.True); + AddAssert("footer shown", () => ScreenFooter.State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("push screen with no footer", () => LoadScreen(screenWithoutFooter = new TestScreen(showFooter: false))); + AddUntilStep("wait until screen is loaded", () => screenWithoutFooter.IsCurrentScreen(), () => Is.True); + AddAssert("footer hidden", () => ScreenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("exit screen", () => Stack.Exit()); + AddUntilStep("wait until screen is loaded", () => screen.IsCurrentScreen(), () => Is.True); + AddAssert("footer shown", () => ScreenFooter.State.Value, () => Is.EqualTo(Visibility.Visible)); + } + + [Test] + public void TestExternalOverlayContent() + { + TestScreen screen = null!; + + AddStep("push screen", () => + { + ShearedOverlayContainer overlay = new TestShearedOverlayContainer(); + + LoadScreen(screen = new TestScreen + { + Overlay = overlay, + CreateButtons = () => new[] + { + new ScreenFooterButton(overlay) + { + AccentColour = Dependencies.Get().Orange1, + Icon = FontAwesome.Solid.Toolbox, + Text = "One", + }, + new ScreenFooterButton { Text = "Two", Action = () => { } }, + new ScreenFooterButton { Text = "Three", Action = () => { } }, + }, + }); + }); + AddUntilStep("wait until screen is loaded", () => screen.IsCurrentScreen(), () => Is.True); + + AddStep("show overlay", () => screen.Overlay.Show()); + contentDisplayed(); + AddAssert("other buttons hidden", () => ScreenFooter.ChildrenOfType().Skip(1).All(b => b.Child.Parent!.Y > 0)); + + AddStep("hide overlay", () => screen.Overlay.Hide()); + contentHidden(); + AddAssert("other buttons returned", () => ScreenFooter.ChildrenOfType().Skip(1).All(b => b.ChildrenOfType().First().Y == 0)); + } + + [Test] + public void TestTemporarilyShowFooter() + { + TestScreen screen = null!; + + AddStep("push screen", () => LoadScreen(screen = new TestScreen(showFooter: false))); + AddUntilStep("wait until screen is loaded", () => screen.IsCurrentScreen(), () => Is.True); + AddAssert("footer hidden", () => ScreenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("show overlay", () => screen.Overlay.Show()); + AddAssert("footer shown", () => ScreenFooter.State.Value, () => Is.EqualTo(Visibility.Visible)); + contentDisplayed(); + + AddStep("hide overlay", () => screen.Overlay.Hide()); + AddAssert("footer hidden", () => ScreenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); + contentHidden(); + } + + [Test] + public void TestShowOverlayHidesOtherOverlays() + { + TestScreen screen = null!; + + AddStep("push screen", () => + { + ShearedOverlayContainer overlay = new TestShearedOverlayContainer(); + ModSelectOverlay secondOverlay = new ModSelectOverlay(); + + LoadScreen(screen = new TestScreen + { + Overlay = overlay, + SecondOverlay = secondOverlay, + CreateButtons = () => new[] + { + new ScreenFooterButton(overlay) + { + AccentColour = Dependencies.Get().Orange1, + Icon = FontAwesome.Solid.Toolbox, + Text = "One", + }, + new FooterButtonMods(secondOverlay), + new ScreenFooterButton { Text = "Two", Action = () => { } }, + new ScreenFooterButton { Text = "Three", Action = () => { } }, + }, + }); + }); + AddUntilStep("wait until screen is loaded", () => screen.IsCurrentScreen(), () => Is.True); + + AddStep("show mods overlay", () => ScreenFooter.ChildrenOfType().First().TriggerClick()); + AddUntilStep("wait until overlay is shown", () => screen.SecondOverlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddAssert("first button still visible", () => ScreenFooter.ChildrenOfType().First(b => b.Text == "One").Y, () => Is.EqualTo(0)); + + AddStep("show test overlay", () => ScreenFooter.ChildrenOfType().First(b => b.Text == "One").TriggerClick()); + AddUntilStep("wait until overlay is shown", () => screen.Overlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddAssert("mod overlay is hidden", () => screen.SecondOverlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("hide test overlay", () => screen.Overlay.Hide()); + contentHidden(); + AddAssert("other buttons returned", () => ScreenFooter.ChildrenOfType().Skip(1).All(b => b.ChildrenOfType().First().Y == 0)); + } + + [Test] + public void TestButtonResizedAfterFooterIsDisplayed() + { + TestScreen screen = null!; + + const float initial_width = 116; + const float width_increase = 124; + + float secondButtonX = 0; + float overlayContentX = 0; + + AddStep("push screen", () => + { + ShearedOverlayContainer overlay = new TestShearedOverlayContainer(); + + LoadScreen(screen = new TestScreen + { + Overlay = overlay, + CreateButtons = () => new[] + { + new ScreenFooterButton(overlay) + { + AccentColour = Dependencies.Get().Orange1, + Icon = FontAwesome.Solid.Toolbox, + Text = "One", + }, + new ScreenFooterButton { Text = "Two", Action = () => { } }, + new ScreenFooterButton { Text = "Three", Action = () => { } }, + }, + }); + }); + AddUntilStep("wait until screen is loaded", () => screen.IsCurrentScreen(), () => Is.True); + AddStep("save second button position", () => secondButtonX = ScreenFooter.ChildrenOfType().ElementAt(1).X); + + AddStep("resize active button", () => ScreenFooter.ChildrenOfType().First().ResizeWidthTo(initial_width + width_increase, 300, Easing.OutQuint)); + AddUntilStep("second button moved", () => ScreenFooter.ChildrenOfType().ElementAt(1).X, () => Is.EqualTo(secondButtonX + width_increase).Within(0.001)); + AddStep("resize active button back", () => this.ChildrenOfType().First().ResizeWidthTo(initial_width, 300, Easing.OutQuint)); + AddUntilStep("second button moved back", () => ScreenFooter.ChildrenOfType().ElementAt(1).X, () => Is.EqualTo(secondButtonX).Within(0.001)); + + AddStep("show overlay", () => screen.Overlay.Show()); + contentDisplayed(); + AddAssert("other buttons hidden", () => ScreenFooter.ChildrenOfType().Skip(1).All(b => b.Child.Parent!.Y > 0)); + AddStep("save overlay content position", () => overlayContentX = ScreenFooter.ChildrenOfType().First().Parent!.Parent!.X); + + AddStep("resize active button", () => ScreenFooter.ChildrenOfType().First().ResizeWidthTo(initial_width + width_increase, 300, Easing.OutQuint)); + AddUntilStep("overlay content moved", () => ScreenFooter.ChildrenOfType().First().Parent!.Parent!.X, () => Is.EqualTo(overlayContentX + width_increase).Within(0.001)); + AddStep("resize active button back", () => this.ChildrenOfType().First().ResizeWidthTo(initial_width, 300, Easing.OutQuint)); + AddUntilStep("overlay content moved back", () => ScreenFooter.ChildrenOfType().First().Parent!.Parent!.X, () => Is.EqualTo(overlayContentX).Within(0.001)); + + AddStep("hide overlay", () => screen.Overlay.Hide()); + contentHidden(); + AddUntilStep("other buttons returned", () => ScreenFooter.ChildrenOfType().Skip(1).All(b => b.ChildrenOfType().First().Y == 0)); + } + + private void contentHidden() + { + AddUntilStep("content hidden from footer", () => ScreenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); + } + + private void contentDisplayed() + { + AddUntilStep("content displayed in footer", () => ScreenFooter.ChildrenOfType().Single().IsPresent); + } + + private partial class TestScreen : OsuScreen + { + public override bool ShowFooter { get; } + + public Func> CreateButtons = Array.Empty; + + public ShearedOverlayContainer Overlay = new TestShearedOverlayContainer(); + public ShearedOverlayContainer SecondOverlay = new TestShearedOverlayContainer(); + + private IDisposable? overlayRegistration; + private IDisposable? secondOverlayRegistration; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + public TestScreen(bool showFooter = true) + { + ShowFooter = showFooter; + } + + [BackgroundDependencyLoader] + private void load() + { + LoadComponent(Overlay); + LoadComponent(SecondOverlay); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + overlayRegistration = overlayManager?.RegisterBlockingOverlay(Overlay); + secondOverlayRegistration = overlayManager?.RegisterBlockingOverlay(SecondOverlay); + } + + public override IReadOnlyList CreateFooterButtons() => CreateButtons.Invoke(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + overlayRegistration?.Dispose(); + secondOverlayRegistration?.Dispose(); + } + } + + private partial class TestShearedOverlayContainer : ShearedOverlayContainer + { + public TestShearedOverlayContainer() + : base(OverlayColourScheme.Orange) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Header.Title = "Test overlay"; + Header.Description = "An overlay that is made purely for testing purposes."; + } + + public override VisibilityContainer CreateFooterContent() => new TestFooterContent(); + + public partial class TestFooterContent : VisibilityContainer + { + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Children = new[] + { + new ShearedButton { Width = 200, Text = "Action #1", Action = () => { } }, + new ShearedButton { Width = 140, Text = "Action #2", Action = () => { } }, + } + }; + } + + protected override void PopIn() + { + this.MoveToY(0, 400, Easing.OutQuint) + .FadeIn(400, Easing.OutQuint); + } + + protected override void PopOut() + { + this.MoveToY(-20f, 200, Easing.OutQuint) + .FadeOut(200, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs index bdec96f44681..f226920cb633 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs @@ -36,8 +36,10 @@ public void TestShearedButton(bool bigButton) if (bigButton) { - Child = button = new ShearedButton(400, 80) + Child = button = new ShearedButton { + Width = 400, + Height = 80, LighterColour = Colour4.FromHex("#FFFFFF"), DarkerColour = Colour4.FromHex("#FFCC22"), TextColour = Colour4.Black, @@ -50,8 +52,10 @@ public void TestShearedButton(bool bigButton) } else { - Child = button = new ShearedButton(200, 80) + Child = button = new ShearedButton { + Width = 200, + Height = 80, LighterColour = Colour4.FromHex("#FF86DD"), DarkerColour = Colour4.FromHex("#DE31AE"), TextColour = Colour4.White, @@ -79,8 +83,9 @@ public void TestShearedToggleButton() AddStep("create button", () => { - Child = button = new ShearedToggleButton(200) + Child = button = new ShearedToggleButton { + Width = 200, Anchor = Anchor.Centre, Origin = Anchor.Centre, Text = "Toggle me", @@ -96,8 +101,9 @@ public void TestSizing() { ShearedToggleButton toggleButton = null; - AddStep("create fixed width button", () => Child = toggleButton = new ShearedToggleButton(200) + AddStep("create fixed width button", () => Child = toggleButton = new ShearedToggleButton { + Width = 200, Anchor = Anchor.Centre, Origin = Anchor.Centre, Text = "Fixed width" @@ -109,6 +115,7 @@ public void TestSizing() AddStep("create auto-sizing button", () => Child = toggleButton = new ShearedToggleButton { + AutoSizeAxes = Axes.X, Anchor = Anchor.Centre, Origin = Anchor.Centre, Text = "This button autosizes to its text!" @@ -130,8 +137,9 @@ public void TestDisabledState() AddStep("create button", () => { - Child = button = new ShearedToggleButton(200) + Child = button = new ShearedToggleButton { + Width = 200, Anchor = Anchor.Centre, Origin = Anchor.Centre, Text = "Toggle me", @@ -186,6 +194,7 @@ public void TestButtons() { new ShearedButton { + AutoSizeAxes = Axes.X, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Text = "Button", @@ -194,6 +203,7 @@ public void TestButtons() }, new ShearedButton { + AutoSizeAxes = Axes.X, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Text = "Button", @@ -202,6 +212,7 @@ public void TestButtons() }, new ShearedButton { + AutoSizeAxes = Axes.X, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Text = "Button", diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs deleted file mode 100644 index 06b9623508f5..000000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs +++ /dev/null @@ -1,131 +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.Linq; -using NUnit.Framework; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Input; -using osu.Framework.Testing; -using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.UserInterface -{ - public partial class TestSceneSliderWithTextBoxInput : OsuManualInputManagerTestScene - { - private SliderWithTextBoxInput sliderWithTextBoxInput = null!; - - private OsuSliderBar slider => sliderWithTextBoxInput.ChildrenOfType>().Single(); - private Nub nub => sliderWithTextBoxInput.ChildrenOfType().Single(); - private OsuTextBox textBox => sliderWithTextBoxInput.ChildrenOfType().Single(); - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("create slider", () => Child = sliderWithTextBoxInput = new SliderWithTextBoxInput("Test Slider") - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, - Current = new BindableFloat - { - MinValue = -5, - MaxValue = 5, - Precision = 0.2f - } - }); - } - - [Test] - public void TestNonInstantaneousMode() - { - AddStep("set instantaneous to false", () => sliderWithTextBoxInput.Instantaneous = false); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("change text", () => textBox.Text = "3"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.Zero); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.Zero); - - AddStep("commit text", () => InputManager.Key(Key.Enter)); - AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3)); - AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); - - AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub)); - AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); - AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft)); - AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("3")); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); - - AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); - AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); - AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("set text to invalid", () => textBox.Text = "garbage"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("commit text", () => InputManager.Key(Key.Enter)); - AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("set text to invalid", () => textBox.Text = "garbage"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("lose focus", () => ((IFocusManager)InputManager).ChangeFocus(null)); - AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - } - - [Test] - public void TestInstantaneousMode() - { - AddStep("set instantaneous to true", () => sliderWithTextBoxInput.Instantaneous = true); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("change text", () => textBox.Text = "3"); - AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3)); - AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); - - AddStep("commit text", () => InputManager.Key(Key.Enter)); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(3)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); - - AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub)); - AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); - AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft)); - AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); - AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); - AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("set text to invalid", () => textBox.Text = "garbage"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("commit text", () => InputManager.Key(Key.Enter)); - AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("set text to invalid", () => textBox.Text = "garbage"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("lose focus", () => ((IFocusManager)InputManager).ChangeFocus(null)); - AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - } - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs index f3ab5dbff87e..9b2a7ae2ef26 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs @@ -4,15 +4,21 @@ #nullable disable using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneSwitchButton : OsuManualInputManagerTestScene { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + private SwitchButton switchButton; [SetUp] @@ -42,5 +48,15 @@ public void TestChangeThroughBindable() AddStep("toggle bindable", () => bindable.Toggle()); AddStep("toggle bindable", () => bindable.Toggle()); } + + [Test] + public void TestDisabledState() + { + AddToggleStep("toggle disabled", v => + { + if (switchButton.IsNotNull()) + switchButton.Current.Disabled = v; + }); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs index 48fe517f8a42..5f70c983d1c5 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs @@ -133,9 +133,9 @@ public void TestUnloadAndReload() var loadedBackgrounds = backgrounds.Where(b => b.ContentLoaded); - AddUntilStep("some loaded", () => loadedBackgrounds.Any()); + AddUntilStep("some loaded", loadedBackgrounds.Any); AddStep("scroll to bottom", () => scrollContainer.ScrollToEnd()); - AddUntilStep("all unloaded", () => !loadedBackgrounds.Any()); + AddUntilStep("all unloaded", loadedBackgrounds.Any, () => Is.False); } private partial class TestUpdateableBeatmapBackgroundSprite : UpdateableBeatmapBackgroundSprite diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs index 54532001a945..6dfb01bb1a37 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs @@ -89,9 +89,9 @@ public void TestUnloadAndReload() var loadedCovers = covers.Where(c => c.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); - AddUntilStep("some loaded", () => loadedCovers.Any()); + AddUntilStep("some loaded", loadedCovers.Any); AddStep("scroll to end", () => scroll.ScrollToEnd()); - AddUntilStep("all unloaded", () => !loadedCovers.Any()); + AddUntilStep("all unloaded", loadedCovers.Any, () => Is.False); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs index a373fbbc51f0..a54844099dfa 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs @@ -22,7 +22,7 @@ public TestSceneUserListToolbar() OsuSpriteText sort; OsuSpriteText displayStyle; - Add(toolbar = new UserListToolbar + Add(toolbar = new UserListToolbar(true) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index c86f05c25702..6f970df4c5cd 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,13 +1,12 @@  - - - + + - - - + + + WinExe diff --git a/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs b/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs index 95d6b6d107fd..28ced3e0ad57 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs @@ -5,6 +5,10 @@ using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; @@ -52,6 +56,7 @@ public void TestSongBar() beatmap.ApproachRate = 6.8f; beatmap.OverallDifficulty = 5.5f; beatmap.StarRating = 4.56f; + beatmap.DrainRate = 1.23f; beatmap.Length = 123456; beatmap.BPM = 133; beatmap.OnlineID = ladderBeatmap.OnlineID; @@ -61,11 +66,18 @@ public void TestSongBar() AddStep("set mods to HR", () => songBar.Mods = LegacyMods.HardRock); AddStep("set mods to DT", () => songBar.Mods = LegacyMods.DoubleTime); + AddStep("set mods to HDHRDT", () => songBar.Mods = LegacyMods.Hidden | LegacyMods.HardRock | LegacyMods.DoubleTime); + AddStep("unset mods", () => songBar.Mods = LegacyMods.None); AddToggleStep("toggle expanded", expanded => songBar.Expanded = expanded); AddStep("set null beatmap", () => songBar.Beatmap = null); + + AddStep("set ruleset to osu", () => Ruleset.Value = new OsuRuleset().RulesetInfo); + AddStep("set ruleset to taiko", () => Ruleset.Value = new TaikoRuleset().RulesetInfo); + AddStep("set ruleset to catch", () => Ruleset.Value = new CatchRuleset().RulesetInfo); + AddStep("set ruleset to mania", () => Ruleset.Value = new ManiaRuleset().RulesetInfo); } } } diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs index 231bd776554a..4b1d56dea22b 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs @@ -6,6 +6,8 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat; @@ -61,14 +63,33 @@ public TestSceneTournamentMatchChatDisplay() Anchor = Anchor.Centre, Origin = Anchor.Centre, }); - - chatDisplay.Channel.Value = testChannel; } protected override void LoadComplete() { base.LoadComplete(); + AddStep("set up API", () => + { + ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case JoinChannelRequest joinChannelRequest: + joinChannelRequest.TriggerSuccess(); + return true; + + case LeaveChannelRequest leaveChannelRequest: + leaveChannelRequest.TriggerSuccess(); + return true; + + default: + return false; + } + }; + }); + AddStep("set channel", () => chatDisplay.Channel.Value = testChannel); + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(nextMessageId()) { Sender = admin, diff --git a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs index 6ee780809917..c488f4ae80b0 100644 --- a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Allocation; using osu.Framework.Platform; using osu.Framework.Testing; @@ -38,8 +39,8 @@ public void CheckIPCLocation() WaitForOrAssert(() => (ipc = osu.Dependencies.Get() as FileBasedIPC)?.IsLoaded == true, @"ipc could not be populated in a reasonable amount of time"); - Assert.True(ipc!.SetIPCLocation(testStableInstallDirectory)); - Assert.True(storage.AllTournaments.Exists("stable.json")); + ClassicAssert.True(ipc!.SetIPCLocation(testStableInstallDirectory)); + ClassicAssert.True(storage.AllTournaments.Exists("stable.json")); } finally { diff --git a/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs index e4a35913cc7c..eb5865bb9727 100644 --- a/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Platform; namespace osu.Game.Tournament.Tests.NonVisual @@ -27,7 +28,7 @@ public static void WaitForOrAssert(Func result, string failureMessage, int while (!result()) Thread.Sleep(200); }); - Assert.IsTrue(task.Wait(timeout), failureMessage); + ClassicAssert.True(task.Wait(timeout), failureMessage); } } } diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs index a3890bbff031..f0a81a71b4d2 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs @@ -23,6 +23,8 @@ public partial class TestSceneSeedingScreen : TournamentScreenTestScene { FullName = { Value = @"Japan" }, Acronym = { Value = "JPN" }, + Seed = { Value = "#28" }, + LastYearPlacing = { Value = "#17-24" }, SeedingResults = { new SeedingResult @@ -36,20 +38,38 @@ public partial class TestSceneSeedingScreen : TournamentScreenTestScene Seed = { Value = 8 } } } + }, + new TournamentTeam + { + Acronym = { Value = "USA" }, + FlagName = { Value = "US" }, + FullName = { Value = "United States" }, } } }; - [Test] - public void TestBasic() + [BackgroundDependencyLoader] + private void load() { - AddStep("create seeding screen", () => Add(new SeedingScreen + Add(new SeedingScreen { FillMode = FillMode.Fit, FillAspectRatio = 16 / 9f - })); + }); + } + + [Test] + public void TestBasic() + { + AddStep("set team to Japan", () => + this.ChildrenOfType().Single().Current.Value = ladder.Teams.Single(t => t.FullName.Value == "Japan")); + } - AddStep("set team to Japan", () => this.ChildrenOfType().Single().Current.Value = ladder.Teams.Single()); + [Test] + public void TestNoSeed() + { + AddStep("set team to USA", () => + this.ChildrenOfType().Single().Current.Value = ladder.Teams.Single(t => t.FullName.Value == "United States")); } } } diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs index 4106556ee12b..e459bb60c6ab 100644 --- a/osu.Game.Tournament.Tests/TournamentTestScene.cs +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -66,7 +66,7 @@ public virtual void SetUpSteps() Acronym = { Value = "JPN" }, FlagName = { Value = "JP" }, FullName = { Value = "Japan" }, - LastYearPlacing = { Value = 10 }, + LastYearPlacing = { Value = "#10" }, Seed = { Value = "#12" }, SeedingResults = { diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 8437a1bc4e5b..83637ab25174 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -4,9 +4,9 @@ osu.Game.Tournament.Tests.TournamentTestRunner - - - + + + WinExe diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index cff86cf0a11b..11cb04e5405a 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,6 +15,7 @@ using osu.Game.Graphics; using osu.Game.Models; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.Menu; using osu.Game.Utils; using osuTK; @@ -123,27 +125,19 @@ private void refreshContent() }, }; - double bpm = beatmap.BPM; - double length = beatmap.Length; - string hardRockExtra = ""; - string srExtra = ""; + var rulesetInstance = ruleset.Value.CreateInstance(); - float ar = beatmap.Difficulty.ApproachRate; + var convertedMods = rulesetInstance.ConvertFromLegacyMods(mods).ToList(); + var adjustedDifficulty = rulesetInstance.GetAdjustedDisplayDifficulty(beatmap, convertedMods); - if ((mods & LegacyMods.HardRock) > 0) - { - hardRockExtra = "*"; - srExtra = "*"; - } + double rate = ModUtils.CalculateRateWithMods(convertedMods); + double bpm = FormatUtils.RoundBPM(beatmap.BPM, rate); + double length = beatmap.Length / rate; - if ((mods & LegacyMods.DoubleTime) > 0) - { - // temporary local calculation (taken from OsuDifficultyCalculator) - double preempt = (int)IBeatmapDifficultyInfo.DifficultyRange(ar, 1800, 1200, 450) / 1.5; - ar = (float)(preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5); + string srExtra = ""; - bpm *= 1.5f; - length /= 1.5f; + if (convertedMods.Any(x => x is ModHardRock) || convertedMods.Any(x => x is ModDoubleTime)) + { srExtra = "*"; } @@ -154,9 +148,9 @@ private void refreshContent() default: stats = new (string heading, string content)[] { - ("CS", $"{beatmap.Difficulty.CircleSize:0.#}{hardRockExtra}"), - ("AR", $"{ar:0.#}{hardRockExtra}"), - ("OD", $"{beatmap.Difficulty.OverallDifficulty:0.#}{hardRockExtra}"), + ("CS", $"{adjustedDifficulty.CircleSize:0.#}"), + ("AR", $"{adjustedDifficulty.ApproachRate:0.#}"), + ("OD", $"{adjustedDifficulty.OverallDifficulty:0.#}"), }; break; @@ -164,16 +158,16 @@ private void refreshContent() case 3: stats = new (string heading, string content)[] { - ("OD", $"{beatmap.Difficulty.OverallDifficulty:0.#}{hardRockExtra}"), - ("HP", $"{beatmap.Difficulty.DrainRate:0.#}{hardRockExtra}") + ("OD", $"{adjustedDifficulty.OverallDifficulty:0.#}"), + ("HP", $"{adjustedDifficulty.DrainRate:0.#}") }; break; case 2: stats = new (string heading, string content)[] { - ("CS", $"{beatmap.Difficulty.CircleSize:0.#}{hardRockExtra}"), - ("AR", $"{ar:0.#}"), + ("CS", $"{adjustedDifficulty.CircleSize:0.#}"), + ("AR", $"{adjustedDifficulty.ApproachRate:0.#}"), }; break; } diff --git a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs index c04dbdcdd64f..02fb5a7ae007 100644 --- a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tournament.Components { public partial class TournamentMatchChatDisplay : StandAloneChatDisplay { - private readonly Bindable chatChannel = new Bindable(); + private readonly Bindable channelName = new Bindable(); private ChannelManager? manager; @@ -34,39 +34,33 @@ public TournamentMatchChatDisplay() } [BackgroundDependencyLoader] - private void load(MatchIPCInfo? ipc, IAPIProvider api) + private void load(MatchIPCInfo ipc, IAPIProvider api) { - if (ipc != null) + AddInternal(manager = new ChannelManager(api)); + Channel.BindTo(manager.CurrentChannel); + + channelName.BindTo(ipc.ChatChannel); + channelName.BindValueChanged(c => { - chatChannel.BindTo(ipc.ChatChannel); - chatChannel.BindValueChanged(c => + if (int.TryParse(c.OldValue, out int oldChannelId) && oldChannelId > 0) { - if (string.IsNullOrWhiteSpace(c.NewValue)) - return; - - int id = int.Parse(c.NewValue); - - if (id <= 0) return; - - if (manager == null) - { - AddInternal(manager = new ChannelManager(api)); - Channel.BindTo(manager.CurrentChannel); - } - - foreach (var ch in manager.JoinedChannels.ToList()) - manager.LeaveChannel(ch); + var joinedChannel = manager.JoinedChannels.SingleOrDefault(ch => ch.Id == oldChannelId); + if (joinedChannel != null) + manager.LeaveChannel(joinedChannel); + } + if (int.TryParse(c.NewValue, out int newChannelId) && newChannelId > 0) + { var channel = new Channel { - Id = id, + Id = newChannelId, Type = ChannelType.Public }; manager.JoinChannel(channel); manager.CurrentChannel.Value = channel; - }, true); - } + } + }, true); } public void Expand() => this.FadeIn(300); diff --git a/osu.Game.Tournament/Models/TournamentBeatmap.cs b/osu.Game.Tournament/Models/TournamentBeatmap.cs index a7ba5b7db11c..72669c0ca7e4 100644 --- a/osu.Game.Tournament/Models/TournamentBeatmap.cs +++ b/osu.Game.Tournament/Models/TournamentBeatmap.cs @@ -6,6 +6,7 @@ using osu.Game.Extensions; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; +using static osu.Game.Online.API.Requests.Responses.APIBeatmap; namespace osu.Game.Tournament.Models { @@ -31,6 +32,8 @@ public class TournamentBeatmap : IBeatmapInfo, IBeatmapSetOnlineInfo public BeatmapSetOnlineCovers Covers { get; set; } + public IRulesetInfo Ruleset { get; set; } = new APIRuleset(); + public TournamentBeatmap() { } @@ -47,6 +50,7 @@ public TournamentBeatmap(APIBeatmap beatmap) Covers = beatmap.BeatmapSet?.Covers ?? new BeatmapSetOnlineCovers(); EndTimeObjectCount = beatmap.EndTimeObjectCount; TotalObjectCount = beatmap.TotalObjectCount; + Ruleset = beatmap.Ruleset; } public bool Equals(IBeatmapInfo? other) => other is TournamentBeatmap b && this.MatchesOnlineID(b); @@ -83,7 +87,7 @@ public TournamentBeatmap(APIBeatmap beatmap) string IBeatmapInfo.MD5Hash => throw new NotImplementedException(); - IRulesetInfo IBeatmapInfo.Ruleset => throw new NotImplementedException(); + IRulesetInfo IBeatmapInfo.Ruleset => Ruleset; DateTimeOffset IBeatmapSetOnlineInfo.Submitted => throw new NotImplementedException(); diff --git a/osu.Game.Tournament/Models/TournamentTeam.cs b/osu.Game.Tournament/Models/TournamentTeam.cs index 95858240a8ec..4368c2fda87e 100644 --- a/osu.Game.Tournament/Models/TournamentTeam.cs +++ b/osu.Game.Tournament/Models/TournamentTeam.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; @@ -49,11 +50,9 @@ public double AverageRank public Bindable Seed = new Bindable(string.Empty); - public Bindable LastYearPlacing = new BindableInt - { - MinValue = 0, - MaxValue = 256 - }; + [JsonProperty] + [JsonConverter(typeof(LastYearPlacingConverter))] + public Bindable LastYearPlacing = new Bindable(@"N/A"); [JsonProperty] public BindableList Players { get; } = new BindableList(); @@ -76,5 +75,37 @@ public TournamentTeam() } public override string ToString() => FullName.Value ?? Acronym.Value; + + public class LastYearPlacingConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Bindable); + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + => serializer.Serialize(writer, ((Bindable)value!).Value); + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var lastYearPlacing = existingValue as Bindable; + Debug.Assert(lastYearPlacing != null); + + switch (reader.TokenType) + { + case JsonToken.String: + lastYearPlacing.Value = (string?)reader.Value ?? lastYearPlacing.Default; + break; + + case JsonToken.Integer: + long value = (long)reader.Value!; + lastYearPlacing.Value = value > 0 ? $@"#{value}" : lastYearPlacing.Default; + break; + + default: + reader.Read(); + break; + } + + return lastYearPlacing; + } + } } } diff --git a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs index 253cca8c981d..6548a26a5119 100644 --- a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs @@ -98,7 +98,7 @@ public RoundRow(TournamentRound round) Width = 0.2f, Margin = new MarginPadding(10), Text = "Add beatmap", - Action = () => beatmapEditor.CreateNew() + Action = beatmapEditor.CreateNew }, beatmapEditor } diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index 9927dd56a05f..d1e14ecf0cf6 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -80,7 +80,7 @@ public SeedingResultRow(TournamentTeam team, SeedingResult round) Width = 0.2f, Margin = new MarginPadding(10), Text = "Add beatmap", - Action = () => beatmapEditor.CreateNew() + Action = beatmapEditor.CreateNew }, beatmapEditor } diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index 162379f4aa85..31828d1c4f06 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -10,9 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Localisation; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Settings; using osu.Game.Tournament.Models; @@ -111,48 +109,48 @@ public TeamRow(TournamentTeam team, TournamentScreen parent) new SettingsTextBox { LabelText = "Name", - Width = 0.2f, + Width = 0.33f, Current = Model.FullName }, acronymTextBox = new SettingsTextBox { LabelText = "Acronym", - Width = 0.2f, + Width = 0.25f, Current = Model.Acronym }, new SettingsTextBox { LabelText = "Flag", - Width = 0.2f, + Width = 0.25f, Current = Model.FlagName }, + new SettingsButton + { + Width = 0.33f, + Margin = new MarginPadding { Top = 20 }, + Text = "Edit seeding results", + Action = () => + { + sceneManager?.SetScreen(new SeedingEditorScreen(team, parent)); + } + }, new SettingsTextBox { LabelText = "Seed", - Width = 0.2f, + Width = 0.25f, Current = Model.Seed }, - new SettingsSlider + new SettingsTextBox { LabelText = "Last Year Placement", - Width = 0.33f, + Width = 0.25f, Current = Model.LastYearPlacing }, - new SettingsButton - { - Width = 0.2f, - Margin = new MarginPadding(10), - Text = "Edit seeding results", - Action = () => - { - sceneManager?.SetScreen(new SeedingEditorScreen(team, parent)); - } - }, playerEditor, new SettingsButton { Text = "Add player", - Action = () => playerEditor.CreateNew() + Action = playerEditor.CreateNew }, new Container { @@ -200,11 +198,6 @@ protected override void LoadComplete() }, true); } - private partial class LastYearPlacementSlider : RoundedSliderBar - { - public override LocalisableString TooltipText => Current.Value == 0 ? "N/A" : base.TooltipText; - } - public partial class PlayerEditor : CompositeDrawable { private readonly TournamentTeam team; diff --git a/osu.Game.Tournament/Screens/Setup/SetupScreen.cs b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs index 536e8ba76719..6bed3c87dfb7 100644 --- a/osu.Game.Tournament/Screens/Setup/SetupScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; @@ -56,7 +55,7 @@ private void load(FrameworkConfigManager frameworkConfig) new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.2f), + Colour = ColourProvider.Background5, }, new OsuScrollContainer { @@ -116,12 +115,13 @@ private void reload() Failing = api.IsLoggedIn != true, Description = "In order to access the API and display metadata, signing in is required." }, - new LabelledDropdown + new LabelledDropdown(padded: true) { Label = "Ruleset", Description = "Decides what stats are displayed and which ranks are retrieved for players. This requires a restart to reload data for an existing bracket.", Items = rulesets.AvailableRulesets, Current = LadderInfo.Ruleset, + DropdownWidth = 0.5f, }, new TournamentSwitcher { diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index 899d462e4eba..ddfff54ded1a 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -274,7 +274,7 @@ public LeftInfo(TournamentTeam? team) new TeamDisplay(team) { Margin = new MarginPadding { Bottom = 30 } }, new RowDisplay("Average Rank:", $"#{team.AverageRank:#,0}"), new RowDisplay("Seed:", team.Seed.Value), - new RowDisplay("Last year's placing:", team.LastYearPlacing.Value > 0 ? $"#{team.LastYearPlacing:#,0}" : "N/A"), + new RowDisplay("Last year's placing:", team.LastYearPlacing.Value), new Container { Margin = new MarginPadding { Bottom = 30 } }, } }, diff --git a/osu.Game.Tournament/Screens/TournamentScreen.cs b/osu.Game.Tournament/Screens/TournamentScreen.cs index 1e119e03365e..137153b91df8 100644 --- a/osu.Game.Tournament/Screens/TournamentScreen.cs +++ b/osu.Game.Tournament/Screens/TournamentScreen.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens @@ -15,6 +16,9 @@ public abstract partial class TournamentScreen : CompositeDrawable [Resolved] protected LadderInfo LadderInfo { get; private set; } = null!; + [Cached] + protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + protected TournamentScreen() { RelativeSizeAxes = Axes.Both; diff --git a/osu.Game/Audio/IPreviewTrackOwner.cs b/osu.Game/Audio/IPreviewTrackOwner.cs index 8ab93257a5fd..e9653aad225d 100644 --- a/osu.Game/Audio/IPreviewTrackOwner.cs +++ b/osu.Game/Audio/IPreviewTrackOwner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; + namespace osu.Game.Audio { /// @@ -10,6 +12,7 @@ namespace osu.Game.Audio /// s can cancel the currently playing through the /// global if they're the owner of the playing . /// + [Cached] public interface IPreviewTrackOwner { } diff --git a/osu.Game/Audio/PreviewTrack.cs b/osu.Game/Audio/PreviewTrack.cs index 961990a1bd70..c22f4fcdf0b0 100644 --- a/osu.Game/Audio/PreviewTrack.cs +++ b/osu.Game/Audio/PreviewTrack.cs @@ -32,8 +32,12 @@ public abstract partial class PreviewTrack : Component private void load() { Track = GetTrack(); + if (Track != null) + { Track.Completed += Stop; + Track.Looping = looping; + } } /// @@ -56,6 +60,23 @@ private void load() /// public bool IsRunning => Track?.IsRunning ?? false; + private bool looping; + + /// + /// Whether the track should loop. + /// + public bool Looping + { + get => looping; + set + { + looping = value; + + if (Track != null) + Track.Looping = looping; + } + } + private ScheduledDelegate? startDelegate; /// diff --git a/osu.Game/Audio/SamplePlaybackHelper.cs b/osu.Game/Audio/SamplePlaybackHelper.cs new file mode 100644 index 000000000000..8c7168e7edd1 --- /dev/null +++ b/osu.Game/Audio/SamplePlaybackHelper.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Audio.Sample; +using osu.Framework.Utils; + +namespace osu.Game.Audio +{ + public static class SamplePlaybackHelper + { + /// + /// Plays the provided with a randomised pitch. + /// + /// The to be played. + /// The amount of pitch variation to allow. + /// The that was used for playback. + public static SampleChannel? PlayWithRandomPitch(Sample? sample, double pitchVariation = 0.2f) + { + var chan = sample?.GetChannel(); + if (chan == null) + return null; + + chan.Frequency.Value = RNG.NextDouble(1 - pitchVariation, 1 + pitchVariation); + chan.Play(); + + return chan; + } + + /// + /// Plays a random sample from the given array, with a randomised pitch. + /// + /// An array of to play. + /// The amount of pitch variation to allow. + /// The that was used for playback. + public static SampleChannel? PlayWithRandomPitch(Sample?[]? samples, double pitchVariation = 0.2f) => + PlayWithRandomPitch(samples?[RNG.Next(0, samples.Length)], pitchVariation); + } +} diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index 155ded5747d7..c728f243684c 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -105,6 +105,7 @@ public double GetMostCommonBeatLength() return (beatLength: t.BeatLength, duration: nextTime - currentTime); }) // Aggregate durations into a set of (beatLength, duration) tuples for each beat length + // Rounding is applied here (to 1e-3 milliseconds) to neutralise potential effects of floating point inaccuracies .GroupBy(t => Math.Round(t.beatLength * 1000) / 1000) .Select(g => (beatLength: g.Key, duration: g.Sum(t => t.duration))) // Get the most common one, or 0 as a suitable default (see handling below) @@ -113,7 +114,12 @@ public double GetMostCommonBeatLength() if (mostCommon.beatLength == 0) return TimingControlPoint.DEFAULT_BEAT_LENGTH; - return mostCommon.beatLength; + // Because of the rounding applied to the beat length above, it is possible for the "most common" beat length as determined by the linq query above + // to actually be less or more than the raw range of unrounded beat lengths present in the map + // To ensure this does not become a problem anywhere else further, clamp the result to the known raw range + double minBeatLength = ControlPointInfo.TimingPoints.Min(t => t.BeatLength); + double maxBeatLength = ControlPointInfo.TimingPoints.Max(t => t.BeatLength); + return Math.Clamp(mostCommon.beatLength, minBeatLength, maxBeatLength); } public double AudioLeadIn { get; set; } diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 4ef484cb67e0..6ac8508036ec 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -75,6 +75,13 @@ protected override void LoadComplete() currentMods.BindValueChanged(mods => { + // A change in bindable here doesn't guarantee that mods have actually changed. + // However, we *do* want to make sure that the mod *references* are the same; + // `SequenceEqual()` without a comparer would fall back to `IEquatable`. + // Failing to ensure reference equality can cause setting change tracking to fail later. + if (mods.OldValue.SequenceEqual(mods.NewValue, ReferenceEqualityComparer.Instance)) + return; + modSettingChangeTracker?.Dispose(); Scheduler.AddOnce(updateTrackedBindables); @@ -82,15 +89,37 @@ protected override void LoadComplete() modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); modSettingChangeTracker.SettingChanged += _ => { - debouncedModSettingsChange?.Cancel(); - debouncedModSettingsChange = Scheduler.AddDelayed(updateTrackedBindables, 100); + lock (bindableUpdateLock) + { + debouncedModSettingsChange?.Cancel(); + debouncedModSettingsChange = Scheduler.AddDelayed(updateTrackedBindables, 100); + } }; }, true); } - public void Invalidate(IBeatmapInfo beatmap) + /// + /// Notify this cache that a beatmap has been invalidated/updated. + /// + /// The old beatmap model. + /// The updated beatmap model. + public void Invalidate(IBeatmapInfo oldBeatmap, IBeatmapInfo newBeatmap) { - base.Invalidate(lookup => lookup.BeatmapInfo.Equals(beatmap)); + base.Invalidate(lookup => lookup.BeatmapInfo.Equals(oldBeatmap)); + + lock (bindableUpdateLock) + { + bool trackedBindablesRefreshRequired = false; + + foreach (var bsd in trackedBindables.Where(bsd => bsd.BeatmapInfo.Equals(oldBeatmap))) + { + bsd.BeatmapInfo = newBeatmap; + trackedBindablesRefreshRequired = true; + } + + if (trackedBindablesRefreshRequired) + Scheduler.AddOnce(updateTrackedBindables); + } } /// @@ -195,6 +224,9 @@ private void cancelTrackedBindableUpdate() { lock (bindableUpdateLock) { + debouncedModSettingsChange?.Cancel(); + debouncedModSettingsChange = null; + trackedUpdateCancellationSource.Cancel(); trackedUpdateCancellationSource = new CancellationTokenSource(); @@ -348,7 +380,7 @@ public override int GetHashCode() private class BindableStarDifficulty : Bindable { - public readonly IBeatmapInfo BeatmapInfo; + public IBeatmapInfo BeatmapInfo; public readonly CancellationToken CancellationToken; public BindableStarDifficulty(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken) diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index a6b40a26de78..1f4d370d13e5 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -157,6 +157,12 @@ public bool Equals(BeatmapInfo? other) public bool Equals(IBeatmapInfo? other) => other is BeatmapInfo b && Equals(b); + public override int GetHashCode() + { + // ReSharper disable once NonReadonlyMemberInGetHashCode + return ID.GetHashCode(); + } + public bool AudioEquals(BeatmapInfo? other) => other != null && BeatmapSet != null && other.BeatmapSet != null diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 08a611e3206d..6d1dbaafba2f 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -95,7 +95,7 @@ public BeatmapManager(Storage storage, RealmAccess realm, IAPIProvider? api, Aud protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap? defaultBeatmap, GameHost? host) { - return new WorkingBeatmapCache(BeatmapTrackStore, audioManager, resources, storage, defaultBeatmap, host); + return new WorkingBeatmapCache(BeatmapTrackStore, audioManager, resources, storage, defaultBeatmap, host, Realm); } protected virtual BeatmapImporter CreateBeatmapImporter(Storage storage, RealmAccess realm) => new BeatmapImporter(storage, realm); @@ -154,7 +154,11 @@ public virtual WorkingBeatmap CreateNewDifficulty(BeatmapSetInfo targetBeatmapSe { DifficultyName = NamingUtils.GetNextBestName(targetBeatmapSet.Beatmaps.Select(b => b.DifficultyName), "New Difficulty") }; - var newBeatmap = new Beatmap { BeatmapInfo = newBeatmapInfo }; + var newBeatmap = new Beatmap + { + BeatmapInfo = newBeatmapInfo, + Bookmarks = referenceWorkingBeatmap.Beatmap.Bookmarks.ToArray() + }; foreach (var timingPoint in referenceWorkingBeatmap.Beatmap.ControlPointInfo.TimingPoints) newBeatmap.ControlPointInfo.Add(timingPoint.Time, timingPoint.DeepClone()); @@ -328,6 +332,18 @@ public List GetAllUsableBeatmapSets() .Filter(query, arguments) .FirstOrDefault()?.Detach()); + /// + /// Perform a lookup query on available s for a specific online ID. + /// + /// A matching local beatmap info if existing and in a valid state. + public BeatmapInfo? QueryOnlineBeatmapId(int id) => Realm.Run(r => + r.All() + .ForOnlineId(id) + // See https://github.com/ppy/osu/issues/36234 for why this isn't a SingleOrDefault(). + .FirstOrDefault() + ?.Detach() + ); + /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. /// @@ -476,9 +492,11 @@ public void UndeleteAll() public Task> BeginExternalEditing(BeatmapSetInfo model) => beatmapImporter.BeginExternalEditing(model); - public Task Export(BeatmapSetInfo beatmap) => beatmapExporter.ExportAsync(beatmap.ToLive(Realm)); + public Task Export(BeatmapSetInfo beatmapSet) => beatmapExporter.ExportAsync(beatmapSet.ToLive(Realm)); + + public Task ExportLegacy(BeatmapSetInfo beatmapSet) => legacyBeatmapExporter.ExportAsync(beatmapSet.ToLive(Realm)); - public Task ExportLegacy(BeatmapSetInfo beatmap) => legacyBeatmapExporter.ExportAsync(beatmap.ToLive(Realm)); + public Task ExportLegacy(BeatmapInfo beatmap) => legacyBeatmapExporter.ExportAsync(beatmap.ToLive(Realm)); private void updateHashAndMarkDirty(BeatmapSetInfo setInfo) { diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index 1603a9848c3e..d8bf2c752bc7 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -6,7 +6,7 @@ using JetBrains.Annotations; using Newtonsoft.Json; using osu.Game.Models; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Users; using osu.Game.Utils; using Realms; diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index 64ac69bb0752..72c69393dfae 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using osu.Framework.Extensions.ObjectExtensions; @@ -53,13 +52,11 @@ public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = foreach (BeatmapInfo beatmap in beatmapSet.Beatmaps) { - difficultyCache.Invalidate(beatmap); - var working = workingBeatmapCache.GetWorkingBeatmap(beatmap); - var ruleset = working.BeatmapInfo.Ruleset.CreateInstance(); - Debug.Assert(ruleset != null); + difficultyCache.Invalidate(beatmap, working.BeatmapInfo); + var ruleset = working.BeatmapInfo.Ruleset.CreateInstance(); var calculator = ruleset.CreateDifficultyCalculator(working); beatmap.StarRating = calculator.Calculate().StarRating; diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index 8666f0112917..30a6d9516e01 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -169,7 +169,7 @@ public void RemoveGroup(ControlPointGroup group) public double GetClosestSnappedTime(double time, int beatDivisor, double? referenceTime = null) { var timingPoint = TimingPointAt(referenceTime ?? time); - double snappedTime = getClosestSnappedTime(timingPoint, time, beatDivisor); + double snappedTime = getClosestPositiveSnappedTime(timingPoint, time, beatDivisor); if (referenceTime.HasValue) return snappedTime; @@ -197,9 +197,19 @@ public int GetClosestBeatDivisor(double time, double? referenceTime = null) int closestDivisor = 0; double closestTime = double.MaxValue; + // `getClosestSnappedTime()` only returns positive time values. + // due to that, if `time` is allowed to be negative, the loop lower below could return bogus results + // as the "snapped time" will not necessarily be "closest" at that point. + // compensate for this by moving `time` by enough beat lengths to go back to the positives. + if (time < 0) + { + int offsetBeats = (int)Math.Ceiling(-time / timingPoint.BeatLength); + time += offsetBeats * timingPoint.BeatLength; + } + foreach (int divisor in BindableBeatDivisor.PREDEFINED_DIVISORS) { - double distanceFromSnap = Math.Abs(time - getClosestSnappedTime(timingPoint, time, divisor)); + double distanceFromSnap = Math.Abs(time - getClosestPositiveSnappedTime(timingPoint, time, divisor)); if (Precision.DefinitelyBigger(closestTime, distanceFromSnap)) { @@ -211,7 +221,7 @@ public int GetClosestBeatDivisor(double time, double? referenceTime = null) return closestDivisor; } - private static double getClosestSnappedTime(TimingControlPoint timingPoint, double time, int beatDivisor) + private static double getClosestPositiveSnappedTime(TimingControlPoint timingPoint, double time, int beatDivisor) { double beatLength = timingPoint.BeatLength / beatDivisor; double beats = (Math.Max(time, 0) - timingPoint.Time) / beatLength; diff --git a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs index 96838bb1ba8b..36c40e690843 100644 --- a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs +++ b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs @@ -192,6 +192,8 @@ public BundledBeatmapDownloadRequest(IBeatmapSetInfo beatmapSetInfo, bool minimi "2412260 Koto Spirit - Locus of Hexagram.osz", "2412232 Will Stetson - Of Our Time.osz", "2412292 ArXe - Locus Amoenus (feat. Megurine Luka).osz", + "2412328 Akiri - Vespera Stella.osz", + "2412331 takehirotei - Haiboku no Altra Vita.osz", }; private static readonly string[] bundled_osu = @@ -465,7 +467,6 @@ public BundledBeatmapDownloadRequest(IBeatmapSetInfo beatmapSetInfo, bool minimi @"2055329 miraie & blackwinterwells - facade.osz", @"2069877 Sephid - Thunderstrike 1988.osz", @"2119716 Aethoro - Snowy.osz", - @"2120379 Synthion - VIVIDVELOCITY.osz", @"2124805 Frums (unknown ""lambda"") - 19ZZ.osz", @"2127811 Wiklund - Joy of Living (Cut Ver.).osz", }; diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs index 222acbc03989..4c4a0637084f 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs @@ -296,20 +296,20 @@ BeatmapCardStatistic withMargin(BeatmapCardStatistic original) return original; } - statisticsContainer.Content[0][0] = withMargin(new FavouritesStatistic(BeatmapSet) - { - Current = FavouriteState, - }); - - statisticsContainer.Content[1][0] = withMargin(new PlayCountStatistic(BeatmapSet)); - var hypesStatistic = HypesStatistic.CreateFor(BeatmapSet); if (hypesStatistic != null) - statisticsContainer.Content[0][1] = withMargin(hypesStatistic); + statisticsContainer.Content[0][0] = withMargin(hypesStatistic); var nominationsStatistic = NominationsStatistic.CreateFor(BeatmapSet); if (nominationsStatistic != null) - statisticsContainer.Content[1][1] = withMargin(nominationsStatistic); + statisticsContainer.Content[1][0] = withMargin(nominationsStatistic); + + statisticsContainer.Content[0][1] = withMargin(new PlayCountStatistic(BeatmapSet)); + + statisticsContainer.Content[1][1] = withMargin(new FavouritesStatistic(BeatmapSet) + { + Current = FavouriteState, + }); var dateStatistic = BeatmapCardDateStatistic.CreateFor(BeatmapSet); if (dateStatistic != null) diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs index ac9ee94f5611..6974cf81ea92 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs @@ -278,8 +278,8 @@ private IEnumerable createStatistics() if (nominationsStatistic != null) yield return nominationsStatistic; - yield return new FavouritesStatistic(BeatmapSet) { Current = FavouriteState }; yield return new PlayCountStatistic(BeatmapSet); + yield return new FavouritesStatistic(BeatmapSet) { Current = FavouriteState }; var dateStatistic = BeatmapCardDateStatistic.CreateFor(BeatmapSet); if (dateStatistic != null) diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs index 2948e89e6074..cb4c548556a3 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs @@ -2,10 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Utils; namespace osu.Game.Beatmaps.Drawables.Cards.Statistics { @@ -17,8 +17,8 @@ private BeatmapCardDateStatistic(DateTimeOffset dateTime) { this.dateTime = dateTime; - Icon = FontAwesome.Regular.CheckCircle; - Text = dateTime.ToLocalisableString(@"d MMM yyyy"); + Icon = FontAwesome.Solid.CheckCircle; + Text = dateTime.ToLocalisedMediumDate(); } public override object TooltipContent => dateTime; diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/PlayCountStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/PlayCountStatistic.cs index 4ce37b865948..8b6e23862584 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/PlayCountStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/PlayCountStatistic.cs @@ -15,7 +15,7 @@ public partial class PlayCountStatistic : BeatmapCardStatistic { public PlayCountStatistic(IBeatmapSetOnlineInfo onlineInfo) { - Icon = FontAwesome.Regular.PlayCircle; + Icon = FontAwesome.Solid.PlayCircle; Text = onlineInfo.PlayCount.ToMetric(decimals: 1); TooltipText = BeatmapsStrings.PanelPlaycount(onlineInfo.PlayCount.ToLocalisableString(@"N0")); } diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index c9f2f8a4b1df..991654638ff6 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -4,7 +4,6 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -12,7 +11,6 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Overlays; using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -43,6 +41,12 @@ public Bindable Current /// public Color4 DisplayedDifficultyColour => background.Colour; + /// + /// The difficulty text colour currently displayed. + /// Can be used to have other components match the spectrum animation. + /// + public Color4 DisplayedDifficultyTextColour => starsText.Colour; + private readonly Bindable displayedStars = new BindableDouble(); /// @@ -54,9 +58,6 @@ public Bindable Current [Resolved] private OsuColour colours { get; set; } = null!; - [Resolved] - private OverlayColourProvider? colourProvider { get; set; } - /// /// Creates a new using an already computed . /// @@ -160,8 +161,8 @@ protected override void LoadComplete() background.Colour = colours.ForStarDifficulty(s.NewValue); - starIcon.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); - starsText.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4.Black.Opacity(0.75f); + starIcon.Colour = colours.ForStarDifficultyText(s.NewValue); + starsText.Colour = colours.ForStarDifficultyText(s.NewValue); }, true); } } diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index e3ac0e1a3d86..caf8dc048a17 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -170,21 +170,21 @@ private void applySamples(HitObject hitObject) { SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.StartTime + CONTROL_POINT_LENIENCY + 1) ?? SampleControlPoint.DEFAULT; - hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList(); + hitObject.Samples = hitObject.Samples.Select(sampleControlPoint.ApplyTo).ToList(); for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) { double time = hitObject.StartTime + i * hasRepeats.Duration / hasRepeats.SpanCount() + CONTROL_POINT_LENIENCY; var nodeSamplePoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(time) ?? SampleControlPoint.DEFAULT; - hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(o => nodeSamplePoint.ApplyTo(o)).ToList(); + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(nodeSamplePoint.ApplyTo).ToList(); } } else { SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + CONTROL_POINT_LENIENCY) ?? SampleControlPoint.DEFAULT; - hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList(); + hitObject.Samples = hitObject.Samples.Select(sampleControlPoint.ApplyTo).ToList(); } } diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index cfca40104f10..24976717c1e5 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -544,7 +544,7 @@ private string getSampleBank(IList samples, bool banksOnly = fals if (!banksOnly) { int customSampleBank = toLegacyCustomSampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name))); - string sampleFilename = samples.FirstOrDefault(s => string.IsNullOrEmpty(s.Name))?.LookupNames.First() ?? string.Empty; + string sampleFilename = samples.FirstOrDefault(s => s is ConvertHitObjectParser.FileHitSampleInfo)?.LookupNames.First() ?? string.Empty; int volume = samples.FirstOrDefault()?.Volume ?? 100; // We want to ignore custom sample banks and volume when not encoding to the mania game mode, diff --git a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs index 2dd73a254186..0875a60d7510 100644 --- a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs @@ -95,6 +95,31 @@ static double DifficultyRange(double difficulty, double min, double mid, double static double DifficultyRange(double difficulty, DifficultyRange range) => DifficultyRange(difficulty, range.Min, range.Mid, range.Max); + /// + /// Maps a difficulty value [0, 10] to a two-piece linear range of values. + /// Floors the value to `int`, usually to match osu!stable spec. + /// + /// The difficulty value to be mapped. + /// The values that define the two linear ranges. + /// + /// + /// od0 + /// Minimum of the resulting range which will be achieved by a difficulty value of 0. + /// + /// + /// od5 + /// Midpoint of the resulting range which will be achieved by a difficulty value of 5. + /// + /// + /// od10 + /// Maximum of the resulting range which will be achieved by a difficulty value of 10. + /// + /// + /// + /// Value to which the difficulty value maps in the specified range. + static int DifficultyRangeInt(double difficulty, DifficultyRange range) + => (int)DifficultyRange(difficulty, range.Min, range.Mid, range.Max); + /// /// Inverse function to . /// Maps a value returned by the function above back to the difficulty that produced it. diff --git a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs index c591dac36faa..d9e72d7c91dd 100644 --- a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs +++ b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs @@ -205,7 +205,7 @@ public Task FetchCache() // ensure to clobber any and all existing data to avoid accidental corruption. outStream.SetLength(0); - using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false)) + using (var bz2 = BZip2Stream.Create(stream, CompressionMode.Decompress, false)) bz2.CopyTo(outStream); } diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 9957935977b9..75f1bd2a0739 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Diagnostics; using System.IO; using System.Linq; using JetBrains.Annotations; @@ -47,12 +48,13 @@ public class WorkingBeatmapCache : IBeatmapResourceProvider, IWorkingBeatmapCach private readonly LargeTextureStore beatmapPanelTextureStore; private readonly ITrackStore trackStore; private readonly IResourceStore files; + private readonly RealmAccess realm; [CanBeNull] private readonly GameHost host; public WorkingBeatmapCache(ITrackStore trackStore, AudioManager audioManager, IResourceStore resources, IResourceStore files, WorkingBeatmap defaultBeatmap = null, - GameHost host = null) + GameHost host = null, RealmAccess realm = null) { DefaultBeatmap = defaultBeatmap; @@ -63,6 +65,7 @@ public WorkingBeatmapCache(ITrackStore trackStore, AudioManager audioManager, IR largeTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), host?.CreateTextureLoaderStore(files)); beatmapPanelTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), new BeatmapPanelBackgroundTextureLoaderStore(host?.CreateTextureLoaderStore(files))); this.trackStore = trackStore; + this.realm = realm; } public void Invalidate(BeatmapSetInfo info) @@ -102,6 +105,10 @@ public virtual WorkingBeatmap GetWorkingBeatmap([CanBeNull] BeatmapInfo beatmapI beatmapInfo = beatmapInfo.Detach(); + // If this ever gets hit, a request has arrived with an outdated BeatmapInfo. + // An outdated BeatmapInfo may contain a reference to a previous version of the beatmap's files on disk. + Debug.Assert(confirmFileHashIsUpToDate(beatmapInfo), "working beatmap returned with outdated path"); + workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this)); // best effort; may be higher than expected. @@ -111,6 +118,12 @@ public virtual WorkingBeatmap GetWorkingBeatmap([CanBeNull] BeatmapInfo beatmapI } } + private bool confirmFileHashIsUpToDate(BeatmapInfo beatmapInfo) + { + string refetchPath = realm.Run(r => r.Find(beatmapInfo.ID)?.File?.File.Hash); + return refetchPath == null || refetchPath == beatmapInfo.File?.File.Hash; + } + #region IResourceStorageProvider TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore; @@ -118,7 +131,7 @@ public virtual WorkingBeatmap GetWorkingBeatmap([CanBeNull] BeatmapInfo beatmapI ITrackStore IBeatmapResourceProvider.Tracks => trackStore; IRenderer IStorageResourceProvider.Renderer => host?.Renderer ?? new DummyRenderer(); AudioManager IStorageResourceProvider.AudioManager => audioManager; - RealmAccess IStorageResourceProvider.RealmAccess => null!; + RealmAccess IStorageResourceProvider.RealmAccess => realm; IResourceStore IStorageResourceProvider.Files => files; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore); diff --git a/osu.Game/Collections/CollectionDropdown.cs b/osu.Game/Collections/CollectionDropdown.cs index 1e47aff3ec17..e9fec32e1213 100644 --- a/osu.Game/Collections/CollectionDropdown.cs +++ b/osu.Game/Collections/CollectionDropdown.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -23,6 +24,7 @@ namespace osu.Game.Collections { /// /// A dropdown to select the collection to be used to filter results. + /// WARNING: TODO: we have TWO `CollectionDropdowns` with diverging functionality. This is not good. /// public partial class CollectionDropdown : OsuDropdown { @@ -263,11 +265,11 @@ private void addOrRemove() { Debug.Assert(collection != null); - collection.PerformWrite(c => + Task.Run(() => collection.PerformWrite(c => { if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash); - }); + })); } protected override Drawable CreateContent() => (Content)base.CreateContent(); diff --git a/osu.Game/Collections/CollectionToggleMenuItem.cs b/osu.Game/Collections/CollectionToggleMenuItem.cs index 5ad06a72c07d..e0e278e9a386 100644 --- a/osu.Game/Collections/CollectionToggleMenuItem.cs +++ b/osu.Game/Collections/CollectionToggleMenuItem.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.Threading.Tasks; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.UserInterface; @@ -10,7 +11,7 @@ namespace osu.Game.Collections public class CollectionToggleMenuItem : ToggleMenuItem { public CollectionToggleMenuItem(Live collection, IBeatmapInfo beatmap) - : base(collection.PerformRead(c => c.Name), MenuItemType.Standard, state => + : base(collection.PerformRead(c => c.Name), MenuItemType.Standard, state => Task.Run(() => { collection.PerformWrite(c => { @@ -19,7 +20,7 @@ public CollectionToggleMenuItem(Live collection, IBeatmapInfo else c.BeatmapMD5Hashes.Remove(beatmap.MD5Hash); }); - }) + })) { State.Value = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.MD5Hash)); } diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs index c494b830d1b7..5c79549c0a80 100644 --- a/osu.Game/Collections/DrawableCollectionList.cs +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -72,7 +71,9 @@ protected override void OnItemsChanged() var createdItem = flow.Children.SingleOrDefault(item => item.Model.Value.ID == lastCreated); if (createdItem != null) - scroll.ScrollTo(createdItem); + { + ScheduleAfterChildren(() => scroll.ScrollIntoView(createdItem)); + } lastCreated = null; } @@ -104,13 +105,8 @@ private void collectionsChanged(IRealmCollection collections, } } - protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) - { - if (item.ID == scroll.PlaceholderItem.Model.ID) - return scroll.ReplacePlaceholder(); - - return new DrawableCollectionListItem(item, true); - } + protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) => + new DrawableCollectionListItem(item, true); protected override void Dispose(bool isDisposing) { @@ -122,91 +118,22 @@ protected override void Dispose(bool isDisposing) /// The scroll container for this . /// Contains the main flow of and attaches a placeholder item to the end of the list. /// - /// - /// Use to transfer the placeholder into the main list. - /// private partial class Scroll : OsuScrollContainer { - /// - /// The currently-displayed placeholder item. - /// - public DrawableCollectionListItem PlaceholderItem { get; private set; } = null!; - protected override Container Content => content; - private readonly Container content; - - private readonly Container placeholderContainer; + private readonly FillFlowContainer content; public Scroll() { ScrollbarOverlapsContent = false; - base.Content.Add(new FillFlowContainer + base.Content.Add(content = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, LayoutDuration = 200, LayoutEasing = Easing.OutQuint, - Children = new Drawable[] - { - content = new Container { RelativeSizeAxes = Axes.X }, - placeholderContainer = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - } - } }); - - ReplacePlaceholder(); - Debug.Assert(PlaceholderItem != null); - } - - protected override void Update() - { - base.Update(); - - // AutoSizeAxes cannot be used as the height should represent the post-layout-transform height at all times, so that the placeholder doesn't bounce around. - content.Height = ((Flow)Child).Children.Sum(c => c.IsPresent ? c.DrawHeight + 5 : 0); - } - - /// - /// Replaces the current with a new one, and returns the previous. - /// - /// The current . - public DrawableCollectionListItem ReplacePlaceholder() - { - var previous = PlaceholderItem; - - placeholderContainer.Clear(false); - placeholderContainer.Add(PlaceholderItem = new NewCollectionEntryItem()); - - return previous; - } - } - - private partial class NewCollectionEntryItem : DrawableCollectionListItem - { - [Resolved] - private RealmAccess realm { get; set; } = null!; - - public NewCollectionEntryItem() - : base(new BeatmapCollection().ToLiveUnmanaged(), false) - { - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - TextBox.OnCommit += (sender, newText) => - { - if (string.IsNullOrEmpty(TextBox.Text)) - return; - - realm.Write(r => r.Add(new BeatmapCollection(TextBox.Text))); - TextBox.Text = string.Empty; - }; } } diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 3031112333c8..5df746459cbd 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -53,7 +53,11 @@ public DrawableCollectionListItem(Live item, bool isCreated) ShowDragHandle.Value = false; Masking = true; - CornerRadius = item_height / 2; + + // This doesn't match the latest design spec (should be 5) but is an in-between that feels right to the eye + // until we move everything over to Form controls. + CornerRadius = 10; + CornerExponent = 2.5f; } protected override Drawable CreateContent() => content = new ItemContent(Model); @@ -135,7 +139,8 @@ public ItemTextBox(Live collection) { this.collection = collection; - CornerRadius = item_height / 2; + CornerRadius = 10; + CornerExponent = 2.5f; } [BackgroundDependencyLoader] diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index 79166840f9cf..958e16954292 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -102,12 +103,13 @@ private void load(OsuColour colours) new Container { RelativeSizeAxes = Axes.Both, + Masking = true, Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeaFoamDarker + Colour = colours.GreySeaFoamDarker, }, new Container { @@ -115,23 +117,30 @@ private void load(OsuColour colours) Padding = new MarginPadding(10), Children = new Drawable[] { + list = new DrawableCollectionList + { + Padding = new MarginPadding { Vertical = 50 }, + RelativeSizeAxes = Axes.Both, + }, searchTextBox = new BasicSearchTextBox { RelativeSizeAxes = Axes.X, - Y = 10, Height = 40, ReleaseFocusOnCommit = false, HoldFocus = true, PlaceholderText = HomeStrings.SearchPlaceholder, }, - list = new DrawableCollectionList + new Container { - Padding = new MarginPadding + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Children = new Drawable[] { - Top = 60, - }, - RelativeSizeAxes = Axes.Both, - } + new NewCollectionEntryItem() + } + }, } }, } @@ -184,5 +193,30 @@ protected override void PopOut() // Ensure that textboxes commit GetContainingFocusManager()?.TriggerFocusContention(this); } + + private partial class NewCollectionEntryItem : DrawableCollectionListItem + { + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public NewCollectionEntryItem() + : base(new BeatmapCollection().ToLiveUnmanaged(), false) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TextBox.OnCommit += (_, _) => + { + if (string.IsNullOrEmpty(TextBox.Text)) + return; + + realm.Write(r => r.Add(new BeatmapCollection(TextBox.Text))); + TextBox.Text = string.Empty; + }; + } + } } } diff --git a/osu.Game/Configuration/IntroSequence.cs b/osu.Game/Configuration/IntroSequence.cs index 5672c44bbe24..04323cd14749 100644 --- a/osu.Game/Configuration/IntroSequence.cs +++ b/osu.Game/Configuration/IntroSequence.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; +using osu.Game.Localisation; + namespace osu.Game.Configuration { public enum IntroSequence @@ -8,6 +11,8 @@ public enum IntroSequence Circles, Welcome, Triangles, + + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.IntroRandom))] Random } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index cdccf7eb6107..7037d8c50a28 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -14,14 +14,15 @@ using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Localisation; +using osu.Game.Online.Leaderboards; using osu.Game.Overlays; +using osu.Game.Overlays.Dashboard.Friends; using osu.Game.Overlays.Mods.Input; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.Select.Leaderboards; using osu.Game.Skinning; using osu.Game.Users; @@ -197,7 +198,7 @@ protected override void InitialiseDefaults() SetDefault(OsuSetting.DiscordRichPresence, DiscordRichPresenceMode.Full); - SetDefault(OsuSetting.EditorDim, 0.25f, 0f, 0.75f, 0.25f); + SetDefault(OsuSetting.EditorDim, 0.25f, 0f, 1f, 0.25f); SetDefault(OsuSetting.EditorWaveformOpacity, 0.25f, 0f, 1f, 0.25f); SetDefault(OsuSetting.EditorShowHitMarkers, true); SetDefault(OsuSetting.EditorAutoSeekOnPlacement, true); @@ -234,6 +235,9 @@ protected override void InitialiseDefaults() // intentionally uses `DateTime?` and not `DateTimeOffset?` because the latter fails due to `DateTimeOffset` not implementing `IConvertible` SetDefault(OsuSetting.LastOnlineTagsPopulation, (DateTime?)null); + + SetDefault(OsuSetting.DashboardSortMode, UserSortCriteria.LastVisit); + SetDefault(OsuSetting.DashboardDisplayStyle, OverlayPanelDisplayStyle.Card); } protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) @@ -486,5 +490,8 @@ public enum OsuSetting LastOnlineTagsPopulation, AutomaticallyAdjustBeatmapOffset, + + DashboardSortMode, + DashboardDisplayStyle, } } diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 30cda4047ec8..87df5b3c927d 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -140,7 +140,7 @@ public static IEnumerable CreateSettingsControls(this object obj) LabelText = attr.Label, TooltipText = attr.Description, Current = bNumber, - KeyboardStep = 0.1f, + KeyboardStep = bNumber.Precision, }; break; @@ -151,7 +151,7 @@ public static IEnumerable CreateSettingsControls(this object obj) LabelText = attr.Label, TooltipText = attr.Description, Current = bNumber, - KeyboardStep = 0.1f, + KeyboardStep = (float)bNumber.Precision, }; break; @@ -161,7 +161,8 @@ public static IEnumerable CreateSettingsControls(this object obj) { LabelText = attr.Label, TooltipText = attr.Description, - Current = bNumber + Current = bNumber, + KeyboardStep = bNumber.Precision, }; break; diff --git a/osu.Game/Database/LegacyArchiveExporter.cs b/osu.Game/Database/LegacyArchiveExporter.cs index e4d3ed468170..9e8f5ad9c94f 100644 --- a/osu.Game/Database/LegacyArchiveExporter.cs +++ b/osu.Game/Database/LegacyArchiveExporter.cs @@ -38,7 +38,7 @@ public override void ExportToStream(TModel model, Stream outputStream, ProgressN { var zipWriterOptions = new ZipWriterOptions(CompressionType.Deflate) { - ArchiveEncoding = UseFixedEncoding ? ZipArchiveReader.DEFAULT_ENCODING : new ArchiveEncoding(Encoding.UTF8, Encoding.UTF8) + ArchiveEncoding = UseFixedEncoding ? ZipArchiveReader.DEFAULT_ENCODING : new ArchiveEncoding { Default = Encoding.UTF8, Password = Encoding.UTF8 } }; using (var writer = new ZipWriter(outputStream, zipWriterOptions)) diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index 8d90c9adb499..0ddce163559d 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -2,17 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; +using System.Threading.Tasks; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Timing; +using osu.Game.Extensions; using osu.Game.IO; +using osu.Game.Localisation; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; +using osu.Game.Utils; using osuTK; namespace osu.Game.Database @@ -175,5 +181,54 @@ protected virtual void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playabl } protected override string FileExtension => @".osz"; + + public Task ExportAsync(Live beatmap) => Task.Run(() => + { + string itemFilename = Path.GetFileNameWithoutExtension(beatmap.PerformRead(s => s.File!.Filename.GetValidFilename())); + const string osu_extension = @".osu"; + + if (itemFilename.Length > MAX_FILENAME_LENGTH - osu_extension.Length) + itemFilename = itemFilename.Remove(MAX_FILENAME_LENGTH - osu_extension.Length); + + IEnumerable existingExports = ExportStorage + .GetFiles(string.Empty, $"{itemFilename}*{osu_extension}") + .Concat(ExportStorage.GetDirectories(string.Empty)); + + string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{osu_extension}"); + + ProgressNotification notification = new ProgressNotification + { + State = ProgressNotificationState.Active, + Text = NotificationsStrings.FileExportOngoing(itemFilename), + }; + + PostNotification?.Invoke(notification); + + try + { + beatmap.PerformRead(b => + { + using var exportStream = ExportStorage.CreateFileSafely(filename); + using var inputFile = GetFileContents(b.BeatmapSet!, b.File!); + + if (inputFile == null) + throw new InvalidOperationException($"Beatmap file {b.File!.Filename} could not be opened!"); + + inputFile.CopyTo(exportStream); + }); + } + catch + { + notification.State = ProgressNotificationState.Cancelled; + + // cleanup if export is failed or canceled. + ExportStorage.Delete(filename); + throw; + } + + notification.CompletionText = NotificationsStrings.FileExportFinished(itemFilename); + notification.CompletionClickAction = () => ExportStorage.PresentFileExternally(filename); + notification.State = ProgressNotificationState.Completed; + }); } } diff --git a/osu.Game/Database/LegacyCollectionImporter.cs b/osu.Game/Database/LegacyCollectionImporter.cs index 6d3e3fb76ae7..b8a515b96f65 100644 --- a/osu.Game/Database/LegacyCollectionImporter.cs +++ b/osu.Game/Database/LegacyCollectionImporter.cs @@ -10,6 +10,7 @@ using osu.Framework.Platform; using osu.Game.Collections; using osu.Game.IO.Legacy; +using osu.Game.Localisation; using osu.Game.Overlays.Notifications; namespace osu.Game.Database @@ -63,7 +64,7 @@ public async Task Import(Stream stream) var notification = new ProgressNotification { State = ProgressNotificationState.Active, - Text = "Collections import is initialising..." + Text = NotificationsStrings.CollectionsImportInitialising, }; PostNotification?.Invoke(notification); @@ -71,7 +72,7 @@ public async Task Import(Stream stream) var importedCollections = readCollections(stream, notification); await importCollections(importedCollections).ConfigureAwait(false); - notification.CompletionText = $"Imported {importedCollections.Count} collections"; + notification.CompletionText = NotificationsStrings.CollectionsImportProgress(importedCollections.Count); notification.State = ProgressNotificationState.Completed; } @@ -115,7 +116,7 @@ private List readCollections(Stream stream, ProgressNotificat { if (notification != null) { - notification.Text = "Reading collections..."; + notification.Text = NotificationsStrings.ReadingCollections; notification.Progress = 0; } @@ -150,7 +151,7 @@ private List readCollections(Stream stream, ProgressNotificat if (notification != null) { - notification.Text = $"Imported {i + 1} of {collectionCount} collections"; + notification.Text = NotificationsStrings.CollectionsImportProgressTotal(i + 1, collectionCount); notification.Progress = (float)(i + 1) / collectionCount; } diff --git a/osu.Game/Database/LegacyExporter.cs b/osu.Game/Database/LegacyExporter.cs index 80393c27f725..eec6bf9341b0 100644 --- a/osu.Game/Database/LegacyExporter.cs +++ b/osu.Game/Database/LegacyExporter.cs @@ -10,6 +10,7 @@ using osu.Framework.Platform; using osu.Game.Extensions; using osu.Game.IO; +using osu.Game.Localisation; using osu.Game.Overlays.Notifications; using osu.Game.Utils; using Realms; @@ -41,13 +42,13 @@ public abstract class LegacyExporter protected abstract string FileExtension { get; } protected readonly Storage UserFileStorage; - private readonly Storage exportStorage; + protected readonly Storage ExportStorage; public Action? PostNotification { get; set; } protected LegacyExporter(Storage storage) { - exportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports"); + ExportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports"); UserFileStorage = storage.GetStorageForDirectory(@"files"); } @@ -74,16 +75,16 @@ public async Task ExportAsync(Live model, CancellationToken cancellation if (itemFilename.Length > MAX_FILENAME_LENGTH - FileExtension.Length) itemFilename = itemFilename.Remove(MAX_FILENAME_LENGTH - FileExtension.Length); - IEnumerable existingExports = exportStorage + IEnumerable existingExports = ExportStorage .GetFiles(string.Empty, $"{itemFilename}*{FileExtension}") - .Concat(exportStorage.GetDirectories(string.Empty)); + .Concat(ExportStorage.GetDirectories(string.Empty)); string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{FileExtension}"); ProgressNotification notification = new ProgressNotification { State = ProgressNotificationState.Active, - Text = $"Exporting {itemFilename}...", + Text = NotificationsStrings.FileExportOngoing(itemFilename), }; PostNotification?.Invoke(notification); @@ -92,7 +93,7 @@ public async Task ExportAsync(Live model, CancellationToken cancellation try { - using (var stream = exportStorage.CreateFileSafely(filename)) + using (var stream = ExportStorage.CreateFileSafely(filename)) { await ExportToStreamAsync(model, stream, notification, linkedSource.Token).ConfigureAwait(false); } @@ -102,12 +103,12 @@ public async Task ExportAsync(Live model, CancellationToken cancellation notification.State = ProgressNotificationState.Cancelled; // cleanup if export is failed or canceled. - exportStorage.Delete(filename); + ExportStorage.Delete(filename); throw; } - notification.CompletionText = $"Exported {itemFilename}! Click to view."; - notification.CompletionClickAction = () => exportStorage.PresentFileExternally(filename); + notification.CompletionText = NotificationsStrings.FileExportFinished(itemFilename); + notification.CompletionClickAction = () => ExportStorage.PresentFileExternally(filename); notification.State = ProgressNotificationState.Completed; } diff --git a/osu.Game/Database/MemoryCachingComponent.cs b/osu.Game/Database/MemoryCachingComponent.cs index a91c60827933..eadfddbbc9d3 100644 --- a/osu.Game/Database/MemoryCachingComponent.cs +++ b/osu.Game/Database/MemoryCachingComponent.cs @@ -76,6 +76,15 @@ protected void Invalidate(Func matchKeyPredicate) statistics.Value.Usage = cache.Count; } + /// + /// Completely purge the cache. + /// + public virtual void Clear() + { + cache.Clear(); + statistics.Value.Usage = 0; + } + protected bool CheckExists(TLookup lookup, [MaybeNullWhen(false)] out TValue value) => cache.TryGetValue(lookup, out value); diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index 8e89db4d069f..235c30b58965 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -9,6 +9,7 @@ using Humanizer; using osu.Framework.Logging; using osu.Game.Extensions; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays.Notifications; @@ -55,7 +56,7 @@ protected bool Download(T model, bool minimiseDownloadSize, TModel? originalMode DownloadNotification notification = new DownloadNotification { - Text = $"Downloading {request.Model.GetDisplayString()}", + Text = NotificationsStrings.Downloading(request.Model.GetDisplayString()), }; request.DownloadProgressed += progress => diff --git a/osu.Game/Database/ModelManager.cs b/osu.Game/Database/ModelManager.cs index e96a8cc1b16a..2eaba596d654 100644 --- a/osu.Game/Database/ModelManager.cs +++ b/osu.Game/Database/ModelManager.cs @@ -12,6 +12,7 @@ using osu.Game.Extensions; using osu.Game.Models; using osu.Game.Overlays.Notifications; +using osu.Game.Utils; using Realms; namespace osu.Game.Database @@ -87,6 +88,10 @@ public void ReplaceFile(RealmNamedFileUsage file, Stream contents, Realm realm) public void AddFile(TModel item, Stream contents, string filename, Realm realm) { filename = filename.ToStandardisedPath(); + + if (FilesystemSanityCheckHelpers.IncursPathTraversalRisk(filename)) + throw new InvalidOperationException($@"Filename ""{filename}"" is not allowed."); + var existing = item.GetFile(filename); if (existing != null) diff --git a/osu.Game/Database/OnlineLookupCache.cs b/osu.Game/Database/OnlineLookupCache.cs index 3b54804fecb2..a8bd5cbff252 100644 --- a/osu.Game/Database/OnlineLookupCache.cs +++ b/osu.Game/Database/OnlineLookupCache.cs @@ -8,6 +8,8 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Extensions; +using osu.Framework.Extensions.ExceptionExtensions; +using osu.Framework.Logging; using osu.Game.Online.API; namespace osu.Game.Database @@ -81,7 +83,7 @@ public abstract partial class OnlineLookupCache : Mem pendingTasks.Enqueue((id, tcs)); // Create a request task if there's not already one. - if (pendingRequestTask == null) + if (pendingRequestTask == null || pendingRequestTask.IsFaulted) createNewTask(); return tcs.Task; @@ -163,6 +165,14 @@ private void finishPendingTask() } } - private void createNewTask() => pendingRequestTask = Task.Run(performLookup); + private void createNewTask() + { + var nextTask = Task.Run(performLookup); + nextTask.ContinueWith(t => + { + Logger.Error(t.Exception.AsSingular(), $"{nameof(OnlineLookupCache)} lookup request failed!"); + }, TaskContinuationOptions.OnlyOnFaulted); + pendingRequestTask = nextTask; + } } } diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index aefb62842210..1836e4ba80ef 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -17,6 +17,7 @@ using osu.Game.IO.Archives; using osu.Game.Models; using osu.Game.Overlays.Notifications; +using osu.Game.Utils; using Realms; namespace osu.Game.Database @@ -221,7 +222,15 @@ public async Task> BeginExternalEditing(TModel mod foreach (string piece in realmFile.Filename.Split('/').Select(f => f.GetValidFilename())) destinationPath = Path.Combine(destinationPath, piece); - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + string destinationDirectory = Path.GetDirectoryName(destinationPath)!; + + if (!FilesystemSanityCheckHelpers.IsSubDirectory(parent: mountedPath, child: destinationDirectory)) + { + Logger.Log($@"Skipping attempt to mount {realmFile.Filename} due to detected escape out of mounted path.", LoggingTarget.Database); + continue; + } + + Directory.CreateDirectory(destinationDirectory); // Consider using hard links here to make this instant. using (var inStream = Files.Storage.GetStream(sourcePath)) @@ -361,6 +370,9 @@ public async Task> BeginExternalEditing(TModel mod // We intentionally delay adding to realm to avoid blocking on a write during disk operations. foreach (var filenames in getShortenedFilenames(archive)) { + if (FilesystemSanityCheckHelpers.IncursPathTraversalRisk(filenames.shortened)) + throw new InvalidOperationException($@"Filename ""{filenames.original}"" is not allowed."); + using (Stream s = archive.GetStream(filenames.original)) files.Add(new RealmNamedFileUsage(Files.Add(s, realm, false, parameters.PreferHardLinks), filenames.shortened)); } @@ -474,8 +486,10 @@ public string ComputeHash(TModel item) foreach (RealmNamedFileUsage file in item.Files.Where(f => HashableFileTypes.Any(ext => f.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f.Filename)) { - using (Stream s = Files.Store.GetStream(file.File.GetStoragePath())) - s.CopyTo(hashable); + using (Stream? s = Files.Store.GetStream(file.File.GetStoragePath())) + { + s?.CopyTo(hashable); + } } if (hashable.Length > 0) diff --git a/osu.Game/Database/RealmExtensions.cs b/osu.Game/Database/RealmExtensions.cs index 1bb6b0aba4a2..65ae42a3da22 100644 --- a/osu.Game/Database/RealmExtensions.cs +++ b/osu.Game/Database/RealmExtensions.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Logging; +using osu.Game.Beatmaps; using Realms; namespace osu.Game.Database @@ -102,5 +104,13 @@ public static T Write(this Realm realm, Func function) /// Quite often we only care about changes at a collection level. This can be used to guard and early-return when no such changes are in a callback. /// public static bool HasCollectionChanges(this ChangeSet changes) => changes.InsertedIndices.Length > 0 || changes.DeletedIndices.Length > 0 || changes.Moves.Length > 0; + + public static IQueryable NotDeleted(this IQueryable beatmaps) => + beatmaps.Filter($@"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false"); + + public static IQueryable ForOnlineId(this IQueryable beatmaps, int id) => + beatmaps + .NotDeleted() + .Filter($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", id); } } diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 2c4d36f7d07d..c334f1152dfc 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -299,7 +299,7 @@ public static IDisposable QueryAsyncWithNotifications(this IRealmCollection + return collection.SubscribeForNotifications((sender, changes) => { if (initial) { @@ -315,7 +315,7 @@ public static IDisposable QueryAsyncWithNotifications(this IRealmCollection diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index 15e3da3c1929..204198a41044 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -51,7 +51,7 @@ public static long GetNewStandardised(ScoreInfo score) var beatmap = new Beatmap(); - HitResult maxRulesetJudgement = ruleset.GetHitResults().First().result; + HitResult maxRulesetJudgement = ruleset.GetHitResultsForDisplay().First().result; // This is a list of all results, ordered from best to worst. // We are constructing a "best possible" score from the statistics provided because it's the best we can do. diff --git a/osu.Game/Extensions/ModelExtensions.cs b/osu.Game/Extensions/ModelExtensions.cs index 7c9d92999918..3b17ee104f42 100644 --- a/osu.Game/Extensions/ModelExtensions.cs +++ b/osu.Game/Extensions/ModelExtensions.cs @@ -8,7 +8,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Users; namespace osu.Game.Extensions diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index d22aa197bb60..f4646e5af609 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -14,8 +14,8 @@ using osu.Framework.Graphics.Primitives; using osu.Framework.Allocation; using System.Collections.Generic; +using Microsoft.Toolkit.HighPerformance; using osu.Framework.Graphics.Rendering; -using osu.Framework.Lists; using osu.Framework.Bindables; namespace osu.Game.Graphics.Backgrounds @@ -94,7 +94,7 @@ public float TriangleScale /// public float Velocity = 1; - private readonly SortedList parts = new SortedList(Comparer.Default); + private readonly List parts = new List(); private Random stableRandom; private IShader shader; @@ -127,9 +127,6 @@ protected override void Update() { base.Update(); - if (CreateNewTriangles) - addTriangles(false); - float adjustedAlpha = HideAlphaDiscrepancies // Cubically scale alpha to make it drop off more sharply. ? MathF.Pow(DrawColourInfo.Colour.AverageColour.Linear.A, 3) @@ -145,19 +142,31 @@ protected override void Update() // dividing by triangleScale. float movedDistance = -elapsedSeconds * Velocity * base_velocity / (DrawHeight * TriangleScale); - for (int i = 0; i < parts.Count; i++) + for (int i = parts.Count - 1; i >= 0; i--) { - TriangleParticle newParticle = parts[i]; + TriangleParticle particle = parts[i]; // Scale moved distance by the size of the triangle. Smaller triangles should move more slowly. - newParticle.Position.Y += Math.Max(0.5f, parts[i].Scale) * movedDistance; - newParticle.Colour.A = adjustedAlpha; + float newY = particle.Position.Y + Math.Max(0.5f, particle.Scale) * movedDistance; + float bottomY = newY + triangle_size * particle.Scale * equilateral_triangle_ratio / DrawHeight; - parts[i] = newParticle; + if (bottomY < 0) + { + if (!CreateNewTriangles) + { + parts.RemoveAt(i); + continue; + } + + particle.Position = getRandomPosition(false, particle.Scale); + } + else + { + particle.Position.Y = newY; + } - float bottomPos = parts[i].Position.Y + triangle_size * parts[i].Scale * equilateral_triangle_ratio / DrawHeight; - if (bottomPos < 0) - parts.RemoveAt(i); + particle.Colour.A = adjustedAlpha; + parts[i] = particle; } Invalidate(Invalidation.DrawNode); @@ -172,30 +181,33 @@ public void Reset(int? seed = null) if (seed != null) stableRandom = new Random(seed.Value); - parts.Clear(); - addTriangles(true); - } - - protected int AimCount { get; private set; } - - private void addTriangles(bool randomY) - { // Limited by the maximum size of QuadVertexBuffer for safety. const int max_triangles = ushort.MaxValue / (IRenderer.VERTICES_PER_QUAD + 2); AimCount = (int)Math.Min(max_triangles, DrawWidth * DrawHeight * 0.002f / (TriangleScale * TriangleScale) * SpawnRatio); - int currentCount = parts.Count; + if (parts.Count == AimCount) + { + var span = parts.AsSpan(); - if (AimCount - currentCount == 0) - return; + for (int i = 0; i < span.Length; i++) + span[i].Position = getRandomPosition(true, span[i].Scale); + } + else + { + parts.Clear(); + + for (int i = 0; i < AimCount; i++) + parts.Add(createTriangle(true)); - for (int i = 0; i < AimCount - currentCount; i++) - parts.Add(createTriangle(randomY)); + parts.Sort(Comparer.Default); + } Invalidate(Invalidation.DrawNode); } + protected int AimCount { get; private set; } + private TriangleParticle createTriangle(bool randomY) { TriangleParticle particle = CreateTriangle(); diff --git a/osu.Game/Graphics/Carousel/Carousel.ScrollContainer.cs b/osu.Game/Graphics/Carousel/Carousel.ScrollContainer.cs index accd74aa4ba8..625c246c4e97 100644 --- a/osu.Game/Graphics/Carousel/Carousel.ScrollContainer.cs +++ b/osu.Game/Graphics/Carousel/Carousel.ScrollContainer.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; @@ -31,8 +32,13 @@ public abstract partial class Carousel where T : notnull /// Implementation of scroll container which handles very large vertical lists by internally using double precision /// for pre-display Y values. /// - protected partial class ScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler + protected partial class ScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler, IKeyBindingHandler { + public Action? OnPageUp { get; init; } + public Action? OnPageDown { get; init; } + public Action? OnListStart { get; init; } + public Action? OnListEnd { get; init; } + public readonly Container Panels; public void SetLayoutHeight(float height) => Panels.Height = height; @@ -127,6 +133,41 @@ protected override float FromScrollbarPosition(float scrollbarPosition) protected override bool IsDragging => base.IsDragging || AbsoluteScrolling; + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.PageUp: + OnPageUp?.Invoke(); + return true; + + case Key.PageDown: + OnPageDown?.Invoke(); + return true; + } + + return base.OnKeyDown(e); + } + + public new bool OnPressed(KeyBindingPressEvent e) + { + if (IsHandlingKeyboardScrolling) + { + switch (e.Action) + { + case PlatformAction.MoveBackwardLine: + OnListStart?.Invoke(); + return true; + + case PlatformAction.MoveForwardLine: + OnListEnd?.Invoke(); + return true; + } + } + + return base.OnPressed(e); + } + public bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) @@ -201,6 +242,8 @@ private partial class ScrollBar : ScrollbarContainer private readonly Drawable box; + private bool capturingMouseDown; + protected override float MinimumDimSize => SCROLL_BAR_WIDTH * 3; private const float expanded_size_ratio = 2; @@ -261,6 +304,7 @@ protected override bool OnMouseDown(MouseDownEvent e) { if (!base.OnMouseDown(e)) return false; + capturingMouseDown = true; updateVisuals(e); return true; } @@ -275,13 +319,14 @@ protected override void OnMouseUp(MouseUpEvent e) { if (e.Button != MouseButton.Left) return; + capturingMouseDown = false; updateVisuals(e); base.OnMouseUp(e); } private void updateVisuals(MouseEvent e) { - if (IsDragged || e.PressedButtons.Contains(MouseButton.Left)) + if (capturingMouseDown) box.FadeColour(highlightColour, 100); else if (IsHovered) box.FadeColour(hoverColour, 100); diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 4a40862a6f3e..5164068d5391 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -317,6 +317,10 @@ protected Carousel() { Masking = false, RelativeSizeAxes = Axes.Both, + OnPageUp = () => Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Page, -1)), + OnPageDown = () => Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Page, 1)), + OnListStart = () => Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Edge, -1)), + OnListEnd = () => Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Edge, 1)), }; Items.BindCollectionChanged((_, args) => @@ -538,30 +542,45 @@ public bool OnPressed(KeyBindingPressEvent e) } return false; + } - void traverseFromKey(TraversalOperation traversal) + private void traverseFromKey(TraversalOperation traversal) + { + switch (traversal.Type) { - switch (traversal.Type) - { - case TraversalType.Keyboard: - traverseKeyboardSelection(traversal.Direction); - break; + case TraversalType.Keyboard: + traverseKeyboardSelection(traversal.Direction); + break; - case TraversalType.Set: - traverseSetSelection(traversal.Direction); - break; + case TraversalType.Page: + traverseKeyboardPage(traversal.Direction); + break; - case TraversalType.Group: - traverseGroupSelection(traversal.Direction); - break; + case TraversalType.Edge: + traverseKeyboardEdge(traversal.Direction); + break; - default: - throw new ArgumentOutOfRangeException(); - } + case TraversalType.Set: + traverseSetSelection(traversal.Direction); + break; + + case TraversalType.Group: + traverseGroupSelection(traversal.Direction); + break; + + default: + throw new ArgumentOutOfRangeException(); } } - private enum TraversalType { Keyboard, Set, Group } + private enum TraversalType + { + Keyboard, + Set, + Page, + Edge, + Group + } private record TraversalOperation(TraversalType Type, int Direction); @@ -617,6 +636,80 @@ private void traverseKeyboardSelection(int direction) } while (newIndex != originalIndex); } + /// + /// Performs a page-wise keyboard traversal in the carousel, moving the selection by approximately one "page" of items. + /// + /// Positive for downwards, negative for upwards. + private void traverseKeyboardPage(int direction) + { + if (carouselItems == null || carouselItems.Count == 0) + return; + + int startIndex = currentKeyboardSelection.Index ?? (direction > 0 ? carouselItems.Count - 1 : 0); + + // Compute the number of visible panels to treat as one page. + // Reduced by 50% to account for the search bar covering the top items. + int visiblePanelsCount = Math.Max(1, Scroll.Panels.Count / 2); + int visibleCount = 0; + int i = startIndex; + + while (i >= 0 && i < carouselItems.Count) + { + i += direction; + + if (i < 0 || i >= carouselItems.Count) + break; + + var item = carouselItems[i]; + + if (!item.IsVisible) + continue; + + visibleCount++; + + if (visibleCount >= visiblePanelsCount) + { + setKeyboardSelection(item.Model); + ScrollToSelection(); + playTraversalSound(); + return; + } + } + + // If we are at the beginning or end and there are not enough items left to scroll through a complete page, then we go to the last or first item. + var fallback = direction > 0 + ? carouselItems.LastOrDefault(x => x.IsVisible) + : carouselItems.FirstOrDefault(x => x.IsVisible); + + if (fallback != null && !CheckModelEquality(fallback.Model, currentKeyboardSelection.Model)) + { + setKeyboardSelection(fallback.Model); + ScrollToSelection(); + playTraversalSound(); + } + } + + /// + /// Select the first or last item in the carousel. + /// + /// Positive for last item, negative for first item. + private void traverseKeyboardEdge(int direction) + { + if (carouselItems == null || carouselItems.Count == 0) + return; + + var item = direction > 0 + ? carouselItems.LastOrDefault(x => x.IsVisible) + : carouselItems.FirstOrDefault(x => x.IsVisible); + + if (item != null && !CheckModelEquality(item.Model, currentKeyboardSelection.Model)) + { + setKeyboardSelection(item.Model); + ScrollToSelection(); + playTraversalSound(); + } + } + /// /// Select the next valid group selection relative to a current selection. /// This is generally for keyboard based traversal. @@ -785,31 +878,19 @@ private void refreshAfterSelection() // We are performing two important operations here: // - Update all Y positions. After a selection occurs, panels may have changed visibility state and therefore Y positions. // - Link selected models to CarouselItems. If a selection changed, this is where we find the relevant CarouselItems for further use. + FindCarouselItemsForSelection(ref currentKeyboardSelection, ref currentSelection, carouselItems); + for (int i = 0; i < count; i++) { var item = carouselItems[i]; - - bool isKeyboardSelection = CheckModelEquality(item.Model, currentKeyboardSelection.Model!); - bool isSelection = CheckModelEquality(item.Model, currentSelection.Model!); - - // while we don't know the Y position of the item yet, as it's about to be updated, - // consumers (specifically `BeatmapCarousel.GetSpacingBetweenPanels()`) benefit from `CurrentSelectionItem` already pointing - // at the correct item to avoid redundant local equality checks. - // the Y positions will be filled in after they're computed. - if (isKeyboardSelection) - currentKeyboardSelection = new Selection(currentKeyboardSelection.Model, item, null, i); - - if (isSelection) - currentSelection = new Selection(currentSelection.Model, item, null, i); - updateItemYPosition(item, ref lastVisible, ref yPos); + } - if (isKeyboardSelection) - currentKeyboardSelection = currentKeyboardSelection with { YPosition = item.CarouselYPosition + item.DrawHeight / 2 }; + if (currentKeyboardSelection.CarouselItem is CarouselItem currentKeyboardSelectionItem) + currentKeyboardSelection = currentKeyboardSelection with { YPosition = currentKeyboardSelectionItem.CarouselYPosition + currentKeyboardSelectionItem.DrawHeight / 2 }; - if (isSelection) - currentSelection = currentSelection with { YPosition = item.CarouselYPosition + item.DrawHeight / 2 }; - } + if (currentSelection.CarouselItem is CarouselItem currentSelectionItem) + currentSelection = currentSelection with { YPosition = currentSelectionItem.CarouselYPosition + currentSelectionItem.DrawHeight / 2 }; // Update the total height of all items (to make the scroll container scrollable through the full height even though // most items are not displayed / loaded). @@ -821,6 +902,27 @@ private void refreshAfterSelection() Scroll.OffsetScrollPosition((float)(currentKeyboardSelection.YPosition!.Value - prevKeyboard.YPosition.Value)); } + protected virtual void FindCarouselItemsForSelection(ref Selection keyboardSelection, ref Selection selection, IList items) + { + for (int i = 0; i < items.Count; i++) + { + var item = items[i]; + + bool isKeyboardSelection = CheckModelEquality(item.Model, keyboardSelection.Model!); + bool isSelection = CheckModelEquality(item.Model, selection.Model!); + + // while we don't know the Y position of the item yet, as it's about to be updated, + // consumers (specifically `BeatmapCarousel.GetSpacingBetweenPanels()`) benefit from `CurrentSelectionItem` already pointing + // at the correct item to avoid redundant local equality checks. + // the Y positions will be filled in after they're computed. + if (isKeyboardSelection) + keyboardSelection = new Selection(keyboardSelection.Model, item, null, i); + + if (isSelection) + selection = new Selection(selection.Model, item, null, i); + } + } + #endregion #region Display handling @@ -1081,7 +1183,7 @@ protected override bool OnInvalidate(Invalidation invalidation, InvalidationSour /// A related carousel item representation for the model. May be null if selection is not present as an item, or if has not been run yet. /// The Y position of the selection as of the last run of . May be null if selection is not present as an item, or if has not been run yet. /// The index of the selection as of the last run of . May be null if selection is not present as an item, or if has not been run yet. - private record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null); + protected record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null); private record DisplayRange(int First, int Last) { diff --git a/osu.Game/Graphics/Containers/ExpandingContainer.cs b/osu.Game/Graphics/Containers/ExpandingContainer.cs index 65a00b725c23..90322e92bc80 100644 --- a/osu.Game/Graphics/Containers/ExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingContainer.cs @@ -4,7 +4,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Events; +using osu.Framework.Input; using osu.Framework.Threading; namespace osu.Game.Graphics.Containers @@ -58,6 +58,19 @@ protected ExpandingContainer(float contractedWidth, float expandedWidth) protected virtual OsuScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + private InputManager inputManager = null!; + + /// + /// Tracks whether the mouse was in bounds of this expanding container in the last frame. + /// + private bool? lastMouseInBounds; + + /// + /// Tracks whether the last expansion of the container was caused by the mouse moving into its bounds + /// (as opposed to an external set of `Expanded`, in which case moving the mouse outside of its bounds should not contract). + /// + private bool? expandedByMouse; + private ScheduledDelegate? hoverExpandEvent; protected override void LoadComplete() @@ -68,37 +81,43 @@ protected override void LoadComplete() { this.ResizeWidthTo(v.NewValue ? expandedWidth : contractedWidth, TRANSITION_DURATION, Easing.OutQuint); }, true); - } - protected override bool OnHover(HoverEvent e) - { - updateHoverExpansion(); - return true; + inputManager = GetContainingInputManager()!; } - protected override void OnHoverLost(HoverLostEvent e) + protected override void Update() { - if (hoverExpandEvent != null) - { - hoverExpandEvent?.Cancel(); - hoverExpandEvent = null; + base.Update(); - Expanded.Value = false; - return; - } + bool mouseInBounds = Contains(inputManager.CurrentState.Mouse.Position); + + if (lastMouseInBounds != mouseInBounds) + updateExpansionState(mouseInBounds); - base.OnHoverLost(e); + lastMouseInBounds = mouseInBounds; } - private void updateHoverExpansion() + private void updateExpansionState(bool mouseInBounds) { if (!ExpandOnHover) return; hoverExpandEvent?.Cancel(); + hoverExpandEvent = null; - if (IsHovered && !Expanded.Value) + if (mouseInBounds && !Expanded.Value) + { hoverExpandEvent = Scheduler.AddDelayed(() => Expanded.Value = true, HoverExpansionDelay); + expandedByMouse = true; + } + + if (!mouseInBounds && Expanded.Value) + { + if (expandedByMouse == true) + Expanded.Value = false; + + expandedByMouse = false; + } } } } diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index 1945b2f0dde6..3c530a3aced3 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -15,7 +15,6 @@ namespace osu.Game.Graphics.Containers { - [Cached(typeof(IPreviewTrackOwner))] public abstract partial class OsuFocusedOverlayContainer : FocusedOverlayContainer, IPreviewTrackOwner, IKeyBindingHandler { protected readonly IBindable OverlayActivationMode = new Bindable(OverlayActivation.All); diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs index fcc48d80ea2b..ab0bd6007e08 100644 --- a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs @@ -10,7 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Utils; +using osu.Game.Audio; namespace osu.Game.Graphics.Containers { @@ -52,12 +52,8 @@ private void playSwapSample() if (Time.Current - sampleLastPlaybackTime <= 35) return; - var channel = sampleSwap?.GetChannel(); - if (channel == null) - return; + SamplePlaybackHelper.PlayWithRandomPitch(sampleSwap, pitchVariation: 0.04); - channel.Frequency.Value = 0.96 + RNG.NextDouble(0.08); - channel.Play(); sampleLastPlaybackTime = Time.Current; } diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 9d2a1c16af99..22dabc55ce09 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; +using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Layout; using osu.Framework.Platform; using osu.Framework.Screens; @@ -50,6 +52,8 @@ public partial class ScalingContainer : Container private RectangleF? customRect; private bool customRectIsRelativePosition; + private ITabletHandler? tabletHandler; + /// /// Set a custom position and scale which overrides any user specification. /// @@ -123,7 +127,7 @@ protected override void Update() } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, ISafeArea safeArea) + private void load(GameHost host, OsuConfigManager config, ISafeArea safeArea) { scalingMode = config.GetBindable(OsuSetting.Scaling); scalingMode.ValueChanged += _ => Scheduler.AddOnce(updateSize); @@ -148,6 +152,8 @@ private void load(OsuConfigManager config, ISafeArea safeArea) scalingMenuBackgroundDim = config.GetBindable(OsuSetting.ScalingBackgroundDim); scalingMenuBackgroundDim.ValueChanged += _ => Scheduler.AddOnce(updateSize); + + tabletHandler = host.AvailableInputHandlers.OfType().SingleOrDefault(); } protected override void LoadComplete() @@ -222,6 +228,13 @@ private void updateSize() // An example of how this can occur is when the skin editor is visible and the game screen scaling is set to "Everything". sizableContainer.TransformTo(nameof(CornerRadius), requiresMasking ? corner_radius : 0, TRANSITION_DURATION, requiresMasking ? Easing.OutQuart : Easing.None) .OnComplete(_ => { sizableContainer.Masking = requiresMasking; }); + + // when "everything" scaling mode is active, tablets are expected to constrain output area to the scaled size of the game + if (tabletHandler != null) + { + tabletHandler.OutputAreaSize.Value = scalingMode.Value == ScalingMode.Everything ? new Vector2(sizeX.Value, sizeY.Value) : Vector2.One; + tabletHandler.OutputAreaOffset.Value = scalingMode.Value == ScalingMode.Everything ? new Vector2(posX.Value, posY.Value) : new Vector2(0.5f); + } } private partial class ScalingBackgroundScreen : BackgroundScreenDefault diff --git a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs index 696ea62b42d8..2ea30d86bd5c 100644 --- a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs +++ b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs @@ -220,16 +220,18 @@ protected override void PopIn() { activeCursor.FadeTo(1, 250, Easing.OutQuint); activeCursor.ScaleTo(1, 400, Easing.OutQuint); - activeCursor.RotateTo(0, 400, Easing.OutQuint); - dragRotationState = DragRotationState.NotDragging; + + if (dragRotationState == DragRotationState.NotDragging) + activeCursor.RotateTo(0, 400, Easing.OutQuint); } protected override void PopOut() { activeCursor.FadeTo(0, 250, Easing.OutQuint); activeCursor.ScaleTo(0.6f, 250, Easing.In); - activeCursor.RotateTo(0, 400, Easing.OutQuint); - dragRotationState = DragRotationState.NotDragging; + + if (dragRotationState == DragRotationState.NotDragging) + activeCursor.RotateTo(0, 400, Easing.OutQuint); } private void playTapSample(double baseFrequency = 1f) diff --git a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs index 0d36cc1d0818..c0f9adac36a1 100644 --- a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; namespace osu.Game.Graphics.Cursor { @@ -93,10 +94,10 @@ private void load(OsuColour colour) protected override void PopIn() { instantMovement |= !IsPresent; - this.FadeIn(500, Easing.OutQuint); + this.FadeIn(300, Easing.OutQuint); } - protected override void PopOut() => this.Delay(150).FadeOut(500, Easing.OutQuint); + protected override void PopOut() => this.Delay(150).FadeOut(300, Easing.OutQuint); public override void Move(Vector2 pos) { @@ -107,7 +108,8 @@ public override void Move(Vector2 pos) } else { - this.MoveTo(pos, 200, Easing.OutQuint); + // This method is called every frame so we can do this safely here. + Position = Interpolation.ValueAt(Time.Elapsed, Position, pos, 0, 120, Easing.OutQuint); } } } diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 0eca359060c1..5dd6dc0c539c 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -25,6 +25,11 @@ public class OsuColour /// public const float STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF = 6.5f; + /// + /// Star rating at which display text switches from static colours to a gradient. + /// + public const float STAR_DIFFICULTY_TEXT_GRADIENT_CUTOFF = 9.0f; + public static readonly (float, Color4)[] STAR_DIFFICULTY_SPECTRUM = { (0.1f, Color4Extensions.FromHex("aaaaaa")), @@ -42,11 +47,34 @@ public static readonly (float, Color4)[] STAR_DIFFICULTY_SPECTRUM = (10.0f, Color4.Black), }; + public static readonly (float, Color4)[] STAR_DIFFICULTY_TEXT_SPECTRUM = + { + (9.0f, Color4Extensions.FromHex("f6f05c")), + (9.9f, Color4Extensions.FromHex("ff8068")), + (10.6f, Color4Extensions.FromHex("ff4e6f")), + (11.5f, Color4Extensions.FromHex("c645b8")), + (12.4f, Color4Extensions.FromHex("6563de")), + }; + /// /// Retrieves the colour for a given point in the star range. /// public Color4 ForStarDifficulty(double starDifficulty) => ColourUtils.SampleFromLinearGradient(STAR_DIFFICULTY_SPECTRUM, (float)Math.Round(starDifficulty, 2, MidpointRounding.AwayFromZero)); + /// + /// Retrieves the colour for the text inside the star rating display. + /// + public Color4 ForStarDifficultyText(double starDifficulty) + { + if (starDifficulty < STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF) + return Color4.Black.Opacity(0.75f); + + if (starDifficulty < STAR_DIFFICULTY_TEXT_GRADIENT_CUTOFF) + return Orange1; + + return ColourUtils.SampleFromLinearGradient(STAR_DIFFICULTY_TEXT_SPECTRUM, (float)Math.Round(starDifficulty, 2, MidpointRounding.AwayFromZero)); + } + /// /// Retrieves the colour for a . /// diff --git a/osu.Game/Graphics/OsuIcon.cs b/osu.Game/Graphics/OsuIcon.cs index 0cf2acadda63..5eccbabf9547 100644 --- a/osu.Game/Graphics/OsuIcon.cs +++ b/osu.Game/Graphics/OsuIcon.cs @@ -28,6 +28,7 @@ public static class OsuIcon public static IconUsage EditCircle => get(OsuIconMapping.EditCircle); public static IconUsage LeftCircle => get(OsuIconMapping.LeftCircle); public static IconUsage RightCircle => get(OsuIconMapping.RightCircle); + public static IconUsage Undo => get(OsuIconMapping.Undo); public static IconUsage Audio => get(OsuIconMapping.Audio); public static IconUsage Beatmap => get(OsuIconMapping.Beatmap); @@ -99,6 +100,7 @@ public static class OsuIcon public static IconUsage EditorSelect => get(OsuIconMapping.EditorSelect); public static IconUsage EditorSound => get(OsuIconMapping.EditorSound); public static IconUsage EditorWhistle => get(OsuIconMapping.EditorWhistle); + public static IconUsage EditorClap => get(OsuIconMapping.EditorClap); public static IconUsage Tortoise => get(OsuIconMapping.Tortoise); public static IconUsage Hare => get(OsuIconMapping.Hare); @@ -386,6 +388,9 @@ private enum OsuIconMapping [Description(@"twitter")] Twitter, + [Description(@"undo")] + Undo, + [Description(@"user-interface")] UserInterface, @@ -422,6 +427,9 @@ private enum OsuIconMapping [Description(@"Editor/whistle")] EditorWhistle, + [Description(@"Editor/clap")] + EditorClap, + [Description(@"tortoise")] Tortoise, diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index a085558b3a33..28795b579900 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -17,6 +17,7 @@ using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Online.Multiplayer; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -169,7 +170,7 @@ public Task TakeScreenshotAsync() => Task.Run(async () => notificationOverlay.Post(new SimpleNotification { - Text = $"Screenshot {filename} saved!", + Text = NotificationsStrings.ScreenshotSaved(filename), Activated = () => { storage.PresentFileExternally(filename); diff --git a/osu.Game/Graphics/UserInterface/DangerousRoundedButton.cs b/osu.Game/Graphics/UserInterface/DangerousRoundedButton.cs index 39ef7924b9ca..cb9250c15c06 100644 --- a/osu.Game/Graphics/UserInterface/DangerousRoundedButton.cs +++ b/osu.Game/Graphics/UserInterface/DangerousRoundedButton.cs @@ -6,7 +6,7 @@ namespace osu.Game.Graphics.UserInterface { - public partial class DangerousRoundedButton : RoundedButton + public sealed partial class DangerousRoundedButton : RoundedButton { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Graphics/UserInterface/ExpandableSlider.cs b/osu.Game/Graphics/UserInterface/ExpandableSlider.cs index 4cc77e218fd0..addf4c91102b 100644 --- a/osu.Game/Graphics/UserInterface/ExpandableSlider.cs +++ b/osu.Game/Graphics/UserInterface/ExpandableSlider.cs @@ -10,6 +10,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; using Vector2 = osuTK.Vector2; namespace osu.Game.Graphics.UserInterface @@ -19,49 +20,27 @@ namespace osu.Game.Graphics.UserInterface /// public partial class ExpandableSlider : CompositeDrawable, IExpandable, IHasCurrentValue where T : struct, INumber, IMinMaxValue - where TSlider : RoundedSliderBar, new() + where TSlider : FormSliderBar, new() { - private readonly OsuSpriteText label; + private readonly OsuSpriteText contractedLabel; private readonly TSlider slider; - private LocalisableString contractedLabelText; - /// /// The label text to display when this slider is in a contracted state. /// public LocalisableString ContractedLabelText { - get => contractedLabelText; - set - { - if (value == contractedLabelText) - return; - - contractedLabelText = value; - - if (!Expanded.Value) - label.Text = value; - } + get => contractedLabel.Text; + set => contractedLabel.Text = value; } - private LocalisableString expandedLabelText; - /// /// The label text to display when this slider is in an expanded state. /// public LocalisableString ExpandedLabelText { - get => expandedLabelText; - set - { - if (value == expandedLabelText) - return; - - expandedLabelText = value; - - if (Expanded.Value) - label.Text = value; - } + get => slider.Caption; + set => slider.Caption = value; } public Bindable Current @@ -95,7 +74,7 @@ public ExpandableSlider() Spacing = new Vector2(0f, 10f), Children = new Drawable[] { - label = new OsuSpriteText(), + contractedLabel = new OsuSpriteText(), slider = new TSlider { RelativeSizeAxes = Axes.X, @@ -118,7 +97,8 @@ protected override void LoadComplete() Expanded.BindValueChanged(v => { - label.Text = v.NewValue ? expandedLabelText : contractedLabelText; + contractedLabel.FadeTo(v.NewValue ? 0 : 1); + slider.FadeTo(v.NewValue ? Current.Disabled ? 0.3f : 1f : 0f, 500, Easing.OutQuint); slider.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None; }, true); @@ -133,7 +113,7 @@ protected override void LoadComplete() /// /// An implementation for the UI slider bar control. /// - public partial class ExpandableSlider : ExpandableSlider> + public partial class ExpandableSlider : ExpandableSlider> where T : struct, INumber, IMinMaxValue { } diff --git a/osu.Game/Graphics/UserInterface/FPSCounter.cs b/osu.Game/Graphics/UserInterface/FPSCounter.cs index 000b85b9004c..190d88a6e464 100644 --- a/osu.Game/Graphics/UserInterface/FPSCounter.cs +++ b/osu.Game/Graphics/UserInterface/FPSCounter.cs @@ -232,14 +232,14 @@ private void requestDisplay() private void updateFpsDisplay() { counterDrawFPS.Colour = getColour(displayedFpsCount / aimDrawFPS); - counterDrawFPS.Text = $"{displayedFpsCount:#,0}fps"; + counterDrawFPS.Text = $"{displayedFpsCount:#,0} fps"; } private void updateFrameTimeDisplay() { counterUpdateFrameTime.Text = displayedFrameTime < 5 - ? $"{displayedFrameTime:N1}ms" - : $"{displayedFrameTime:N0}ms"; + ? $"{displayedFrameTime:N1} ms" + : $"{displayedFrameTime:N0} ms"; counterUpdateFrameTime.Colour = getColour((1000 / displayedFrameTime) / aimUpdateFPS); } diff --git a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs index e64a4c6c0751..2faf03d2f414 100644 --- a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs +++ b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs @@ -82,7 +82,7 @@ protected override void Update() ? $"/{(clock.MaximumUpdateHz > 0 && clock.MaximumUpdateHz < 10000 ? clock.MaximumUpdateHz.ToString("0") : "∞"),4}" : string.Empty; - textFlow.AddParagraph($"{clock.FramesPerSecond:0}{maximum}fps ({clock.ElapsedFrameTime:0.00}ms)"); + textFlow.AddParagraph($"{clock.FramesPerSecond:0}{maximum} fps ({clock.ElapsedFrameTime:0.00} ms)"); } } } diff --git a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs index fea33bfa9d78..f1c14eb6b530 100644 --- a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs @@ -1,16 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Input.Events; -using osu.Framework.Utils; +using osu.Game.Audio; using osuTK.Input; namespace osu.Game.Graphics.UserInterface @@ -21,10 +18,8 @@ namespace osu.Game.Graphics.UserInterface /// public partial class HoverClickSounds : HoverSounds { - public Bindable Enabled = new Bindable(true); - - private Sample sampleClick; - private Sample sampleClickDisabled; + private Sample? sampleClick; + private Sample? sampleClickDisabled; private readonly MouseButton[] buttons; @@ -36,7 +31,7 @@ public partial class HoverClickSounds : HoverSounds /// Array of button codes which should trigger the click sound. /// If this optional parameter is omitted or set to null, the click sound will only be played on left click. /// - public HoverClickSounds(HoverSampleSet sampleSet = HoverSampleSet.Default, MouseButton[] buttons = null) + public HoverClickSounds(HoverSampleSet sampleSet = HoverSampleSet.Default, MouseButton[]? buttons = null) : base(sampleSet) { this.buttons = buttons ?? new[] { MouseButton.Left }; @@ -60,23 +55,7 @@ protected override bool OnClick(ClickEvent e) return base.OnClick(e); } - public override void PlayHoverSample() - { - if (!Enabled.Value) - return; - - base.PlayHoverSample(); - } - - public void PlayClickSample() - { - var channel = Enabled.Value ? sampleClick?.GetChannel() : sampleClickDisabled?.GetChannel(); - - if (channel != null) - { - channel.Frequency.Value = 0.99 + RNG.NextDouble(0.02); - channel.Play(); - } - } + public void PlayClickSample() => + SamplePlaybackHelper.PlayWithRandomPitch(Enabled.Value ? sampleClick : sampleClickDisabled, pitchVariation: 0.01); } } diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index 012594b40434..b305104eeeda 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs @@ -6,9 +6,10 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; -using osu.Framework.Utils; +using osu.Game.Audio; namespace osu.Game.Graphics.UserInterface { @@ -18,6 +19,8 @@ namespace osu.Game.Graphics.UserInterface /// public partial class HoverSounds : HoverSampleDebounceComponent { + public readonly Bindable Enabled = new Bindable(true); + private Sample sampleHover; protected readonly HoverSampleSet SampleSet; @@ -37,8 +40,10 @@ private void load(AudioManager audio) public override void PlayHoverSample() { - sampleHover.Frequency.Value = 0.98 + RNG.NextDouble(0.04); - sampleHover.Play(); + if (!Enabled.Value) + return; + + SamplePlaybackHelper.PlayWithRandomPitch(sampleHover, pitchVariation: 0.02); } } } diff --git a/osu.Game/Graphics/UserInterface/LoadingLayer.cs b/osu.Game/Graphics/UserInterface/LoadingLayer.cs index 916b041696ca..0a5801475e63 100644 --- a/osu.Game/Graphics/UserInterface/LoadingLayer.cs +++ b/osu.Game/Graphics/UserInterface/LoadingLayer.cs @@ -6,7 +6,9 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; @@ -17,20 +19,28 @@ namespace osu.Game.Graphics.UserInterface /// Also optionally dims target elements. /// Useful for disabling all elements in a form and showing we are waiting on a response, for instance. /// - public partial class LoadingLayer : LoadingSpinner + public partial class LoadingLayer : LoadingSpinner, IKeyBindingHandler { - private readonly bool blockInput; + /// + /// Whether to block positional input of components behind the loading layer. + /// Defaults to true. + /// + public bool BlockPositionalInput { get; init; } = true; + + /// + /// Whether to block all keyboard input. Includes global actions. + /// Defaults to false. + /// + public bool BlockNonPositionalInput { get; init; } /// /// Construct a new loading spinner. /// /// Whether the full background area should be dimmed while loading. /// Whether the spinner should have a surrounding black box for visibility. - /// Whether to block input of components behind the loading layer. - public LoadingLayer(bool dimBackground = false, bool withBox = true, bool blockInput = true) + public LoadingLayer(bool dimBackground = false, bool withBox = true) : base(withBox) { - this.blockInput = blockInput; RelativeSizeAxes = Axes.Both; Size = new Vector2(1); @@ -48,11 +58,11 @@ public LoadingLayer(bool dimBackground = false, bool withBox = true, bool blockI } } - public override bool HandleNonPositionalInput => false; + public override bool HandleNonPositionalInput => BlockNonPositionalInput; protected override bool Handle(UIEvent e) { - if (!blockInput) + if (!BlockPositionalInput) return false; switch (e) @@ -76,5 +86,11 @@ protected override void Update() MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 20, 80)); } + + public bool OnPressed(KeyBindingPressEvent e) => BlockNonPositionalInput; + + public void OnReleased(KeyBindingReleaseEvent e) + { + } } } diff --git a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs index b4bc6fb8c3f5..a2247006d73f 100644 --- a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs +++ b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs @@ -80,7 +80,7 @@ public LoadingSpinner(bool withBox = false, bool inverted = false) spinner = new SpriteIcon { Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Origin = Anchor.Custom, Colour = inverted ? Color4.Black : Color4.White, Scale = new Vector2(0.6f), RelativeSizeAxes = Axes.Both, @@ -103,7 +103,7 @@ public LoadingSpinner(bool withBox = false, bool inverted = false) spinner = new SpriteIcon { Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Origin = Anchor.Custom, Colour = inverted ? Color4.Black : Color4.White, RelativeSizeAxes = Axes.Both, Icon = FontAwesome.Solid.CircleNotch @@ -149,6 +149,9 @@ protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); + // Font awesome icon isn't centered perfectly. + spinner.OriginPosition = spinner.DrawSize * 0.4963333333f; + if (withBox) { MainContents.CornerRadius = MainContents.DrawWidth / 4; diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 48d225de416d..87aa4547d4bf 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -56,7 +56,8 @@ public OsuAnimatedButton(HoverSampleSet sampleSet = HoverSampleSet.Button) Origin = Anchor.Centre, Anchor = Anchor.Centre, RelativeSizeAxes = Axes.Both, - CornerRadius = 5, + CornerRadius = 10, + CornerExponent = 2.5f, Masking = true, EdgeEffect = new EdgeEffectParameters { @@ -91,11 +92,11 @@ protected override void LoadComplete() { base.LoadComplete(); - Colour = dimColour; - Enabled.BindValueChanged(_ => this.FadeColour(dimColour, 200, Easing.OutQuint)); + Enabled.BindValueChanged(_ => content.FadeColour(DimColour, 200, Easing.OutQuint), true); + FinishTransforms(true); } - private Color4 dimColour => Enabled.Value ? Color4.White : colours.Gray9; + protected virtual Colour4 DimColour => Enabled.Value ? Color4.White : colours.Gray9; protected override bool OnHover(HoverEvent e) { diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index e0179f8bc428..bbc826a7a7b7 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -104,9 +104,17 @@ protected override void AnimateClose() } } + private Vector2? targetSize; + // todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring protected override void UpdateSize(Vector2 newSize) { + // TODO: should probably fix this at a framework level (this method is running every frame which can spam transforms) + if (newSize == targetSize) + return; + + targetSize = newSize; + if (Direction == Direction.Vertical) { Width = newSize.X; @@ -357,7 +365,8 @@ public OsuDropdownHeader() Icon = FontAwesome.Solid.ChevronDown, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Size = new Vector2(16), + Size = new Vector2(10), + Margin = new MarginPadding { Right = 2 }, }, } } diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index ca95d45042a9..72e3b19a6a27 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -25,12 +25,12 @@ public abstract partial class OsuSliderBar : SliderBar, IHasTooltip /// public bool DisplayAsPercentage { get; set; } - public virtual LocalisableString TooltipText { get; private set; } + public virtual LocalisableString TooltipText { get; protected set; } /// /// Maximum number of decimal digits to be displayed in the tooltip. /// - private const int max_decimal_digits = 5; + public const int MAX_DECIMAL_DIGITS = 5; private Sample sample = null!; @@ -46,7 +46,7 @@ private void load(AudioManager audio) protected override void LoadComplete() { base.LoadComplete(); - CurrentNumber.BindValueChanged(current => TooltipText = GetDisplayableValue(current.NewValue), true); + CurrentNumber.BindValueChanged(current => TooltipText = GetTooltipText(current.NewValue), true); } protected override void OnUserChange(T value) @@ -55,7 +55,7 @@ protected override void OnUserChange(T value) playSample(value); - TooltipText = GetDisplayableValue(value); + TooltipText = GetTooltipText(value); } private void playSample(T value) @@ -83,6 +83,6 @@ private void playSample(T value) channel.Play(); } - public LocalisableString GetDisplayableValue(T value) => value.ToStandardFormattedString(max_decimal_digits, DisplayAsPercentage); + protected virtual LocalisableString GetTooltipText(T value) => value.ToStandardFormattedString(MAX_DECIMAL_DIGITS, DisplayAsPercentage); } } diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 6388f56f6180..fefe776b010c 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -42,6 +42,8 @@ public partial class OsuTextBox : BasicTextBox Margin = new MarginPadding { Left = 2 }, }; + protected bool DrawBorder { get; init; } = true; + private OsuCaret? caret; private bool selectionStarted; @@ -256,7 +258,7 @@ protected override void OnImeResult(string result, bool successful) protected override void OnFocus(FocusEvent e) { - if (Masking) + if (DrawBorder) BorderThickness = 3; base.OnFocus(e); @@ -268,7 +270,7 @@ protected override void OnFocus(FocusEvent e) protected override void OnFocusLost(FocusLostEvent e) { - if (Masking) + if (DrawBorder) BorderThickness = 0; base.OnFocusLost(e); diff --git a/osu.Game/Graphics/UserInterface/ProgressBar.cs b/osu.Game/Graphics/UserInterface/ProgressBar.cs index 8f383c76dbe3..1169d4ca8818 100644 --- a/osu.Game/Graphics/UserInterface/ProgressBar.cs +++ b/osu.Game/Graphics/UserInterface/ProgressBar.cs @@ -13,7 +13,10 @@ namespace osu.Game.Graphics.UserInterface { public partial class ProgressBar : SliderBar { + public bool Seeking { get; private set; } + public Action OnSeek; + public Action OnCommit; private readonly Box fill; private readonly Box background; @@ -75,6 +78,18 @@ protected override void UpdateValue(float value) fill.Width = value * UsableWidth; } - protected override void OnUserChange(double value) => OnSeek?.Invoke(value); + protected override void OnUserChange(double value) + { + Seeking = true; + OnSeek?.Invoke(value); + base.OnUserChange(value); + } + + protected override bool Commit() + { + Seeking = false; + OnCommit?.Invoke(CurrentNumber.Value); + return base.Commit(); + } } } diff --git a/osu.Game/Graphics/UserInterface/SearchTextBox.cs b/osu.Game/Graphics/UserInterface/SearchTextBox.cs index a2e0ab648217..17d714d029cc 100644 --- a/osu.Game/Graphics/UserInterface/SearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/SearchTextBox.cs @@ -54,7 +54,13 @@ protected override bool OnKeyDown(KeyDownEvent e) { case Key.KeypadEnter: case Key.Enter: - return false; + // even if committing per se is not allowed for this textbox, + // the commit flow is also responsible for terminating any active IME. + // ensure that the Enter press terminates IME correctly + // and is also handled if it needs to be, so that it doesn't leak to some other non-focused drawable and cause breakage. + bool wasImeComposing = ImeCompositionActive; + FinalizeImeComposition(true); + return wasImeComposing; } } diff --git a/osu.Game/Graphics/UserInterface/ShearedButton.cs b/osu.Game/Graphics/UserInterface/ShearedButton.cs index 2047fc74f40e..e6737d6a5750 100644 --- a/osu.Game/Graphics/UserInterface/ShearedButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedButton.cs @@ -75,19 +75,15 @@ public Colour4 TextColour protected readonly Container ButtonContent; /// - /// Creates a new + /// Creates a new /// - /// - /// The width of the button. - /// - /// If a non- value is provided, this button will have a fixed width equal to the provided value. - /// If a value is provided (or the argument is omitted entirely), the button will autosize in width to fit the text. - /// - /// - /// The height of the button. - public ShearedButton(float? width = null, float height = DEFAULT_HEIGHT) + /// + /// By default, the button will have a height of . + /// Width should be set for each usage. + /// + public ShearedButton() { - Height = height; + Height = DEFAULT_HEIGHT; Shear = OsuGame.SHEAR; @@ -99,27 +95,25 @@ public ShearedButton(float? width = null, float height = DEFAULT_HEIGHT) { backgroundLayer = new Container { - RelativeSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.Both, CornerRadius = CORNER_RADIUS, Masking = true, BorderThickness = BORDER_THICKNESS, - Children = new Drawable[] + Child = background = new Box { - background = new Box - { - RelativeSizeAxes = Axes.Both - }, - ButtonContent = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Shear = -OsuGame.SHEAR, - Child = text = new OsuSpriteText - { - Font = OsuFont.TorusAlternate.With(size: 17), - } - }, + RelativeSizeAxes = Axes.Both, + }, + }, + ButtonContent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Shear = -OsuGame.SHEAR, + Child = text = new OsuSpriteText + { + Font = OsuFont.TorusAlternate.With(size: 17), + Margin = new MarginPadding { Horizontal = 15 }, } }, flashLayer = new Box @@ -130,18 +124,6 @@ public ShearedButton(float? width = null, float height = DEFAULT_HEIGHT) Alpha = 0, }, }; - - if (width != null) - { - Width = width.Value; - backgroundLayer.RelativeSizeAxes = Axes.Both; - } - else - { - AutoSizeAxes = Axes.X; - backgroundLayer.AutoSizeAxes = Axes.X; - text.Margin = new MarginPadding { Horizontal = 15 }; - } } protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet) { Enabled = { BindTarget = Enabled } }; diff --git a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs index c2f547ba1933..7731425635ca 100644 --- a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs @@ -24,21 +24,6 @@ public partial class ShearedToggleButton : ShearedButton /// public BindableBool Active { get; } = new BindableBool(); - /// - /// Creates a new - /// - /// - /// The width of the button. - /// - /// If a non- value is provided, this button will have a fixed width equal to the provided value. - /// If a value is provided (or the argument is omitted entirely), the button will autosize in width to fit the text. - /// - /// - public ShearedToggleButton(float? width = null) - : base(width) - { - } - [BackgroundDependencyLoader] private void load(AudioManager audio) { diff --git a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index 6f61a14b7568..3083abc39391 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -1,21 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Framework.Audio.Track; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Framework.Extensions.Color4Extensions; -using osu.Game.Graphics.Containers; -using osu.Game.Beatmaps.ControlPoints; -using osu.Framework.Audio.Track; -using System; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; -using osu.Game.Screens.Select; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { @@ -31,7 +30,9 @@ public partial class TwoLayerButton : OsuClickableContainer private const float shear_width = 5f; - private static readonly Vector2 shear = new Vector2(shear_width / Footer.HEIGHT, 0); + public const float HEIGHT = 50; + + private static readonly Vector2 shear = new Vector2(shear_width / HEIGHT, 0); public static readonly Vector2 SIZE_EXTENDED = new Vector2(140, 50); public static readonly Vector2 SIZE_RETRACTED = new Vector2(100, 50); diff --git a/osu.Game/Graphics/UserInterfaceV2/FileSelection/HiddenFilesToggleCheckbox.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelection/HiddenFilesToggleCheckbox.cs index 07d84a009567..79374515e5a4 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FileSelection/HiddenFilesToggleCheckbox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelection/HiddenFilesToggleCheckbox.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -22,7 +23,7 @@ public HiddenFilesToggleCheckbox() Origin = Anchor.CentreLeft; LabelTextFlowContainer.Anchor = Anchor.CentreLeft; LabelTextFlowContainer.Origin = Anchor.CentreLeft; - LabelText = @"Show hidden"; + LabelText = UserInterfaceStrings.ShowHidden; Scale = new Vector2(0.8f); } diff --git a/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs index efeebb2fc113..7d1274315d7b 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs @@ -8,7 +8,9 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; using osu.Game.Overlays; using osuTK; @@ -26,9 +28,9 @@ protected override Drawable CreateCaption() => Empty().With(d => d.Alpha = 0; }); - protected override DirectorySelectorDirectory CreateRootDirectoryItem() => new OsuBreadcrumbDisplayComputer(); + protected override DirectorySelectorDirectory CreateRootDirectoryItem() => new OsuBreadcrumbDisplayDevice(); - protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string? displayName = null) => new OsuBreadcrumbDisplayDirectory(directory, displayName); + protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, LocalisableString? displayName = null) => new OsuBreadcrumbDisplayDirectory(directory, displayName); [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -47,19 +49,19 @@ private void load(OverlayColourProvider colourProvider) }); } - private partial class OsuBreadcrumbDisplayComputer : OsuBreadcrumbDisplayDirectory + private partial class OsuBreadcrumbDisplayDevice : OsuBreadcrumbDisplayDirectory { protected override IconUsage? Icon => null; - public OsuBreadcrumbDisplayComputer() - : base(null, "Computer") + public OsuBreadcrumbDisplayDevice() + : base(null, UserInterfaceStrings.Device) { } } private partial class OsuBreadcrumbDisplayDirectory : DirectorySelectorDirectory { - public OsuBreadcrumbDisplayDirectory(DirectoryInfo? directory, string? displayName = null) + public OsuBreadcrumbDisplayDirectory(DirectoryInfo? directory, LocalisableString? displayName = null) : base(directory, displayName) { } diff --git a/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorDirectory.cs index 0da4e1929f39..741674c765ec 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorDirectory.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorDirectory.cs @@ -6,13 +6,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterfaceV2.FileSelection { internal partial class OsuDirectorySelectorDirectory : DirectorySelectorDirectory { - public OsuDirectorySelectorDirectory(DirectoryInfo directory, string? displayName = null) + public OsuDirectorySelectorDirectory(DirectoryInfo directory, LocalisableString? displayName = null) : base(directory, displayName) { } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs index 85198191b872..afab77490b66 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs @@ -4,11 +4,11 @@ using System; using System.Diagnostics; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; @@ -28,62 +28,118 @@ public partial class FormButton : CompositeDrawable /// public LocalisableString Caption { get; init; } + /// + /// Sets text inside the button. + /// public LocalisableString ButtonText { get; init; } - public Action? Action { get; init; } + /// + /// Sets a custom button icon. Not shown when is set. + /// + public IconUsage ButtonIcon { get; init; } = FontAwesome.Solid.ChevronRight; + + private readonly Color4? backgroundColour; + + /// + /// Sets a custom background colour for the button. + /// + public Color4? BackgroundColour + { + get => backgroundColour; + init + { + backgroundColour = value; + + if (IsLoaded) + updateState(); + } + } + + /// + /// The action to invoke when the button is clicked. + /// + public Action? Action { get; set; } + + /// + /// Whether the button is enabled. + /// + public readonly BindableBool Enabled = new BindableBool(true); [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + private FormControlBackground background = null!; + private OsuTextFlowContainer text = null!; + private Button button = null!; + [BackgroundDependencyLoader] private void load() { RelativeSizeAxes = Axes.X; - Height = 50; - - Masking = true; - CornerRadius = 5; - CornerExponent = 2.5f; + AutoSizeAxes = Axes.Y; - InternalChildren = new Drawable[] + InternalChild = new Container { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 5, + CornerExponent = 2.5f, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Left = 9, - Right = 5, - Vertical = 5, - }, - Children = new Drawable[] + background = new FormControlBackground(), + new Container { - new OsuTextFlowContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Width = 0.45f, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Text = Caption, + Left = 9, + Right = 5, + Vertical = 5, }, - new Button + Children = new Drawable[] { - Action = Action, - Text = ButtonText, - RelativeSizeAxes = ButtonText == default ? Axes.None : Axes.X, - Width = ButtonText == default ? 90 : 0.45f, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - } + text = new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = Caption, + }, + button = new Button + { + Action = () => Action?.Invoke(), + Text = ButtonText, + Icon = ButtonIcon, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Enabled = { BindTarget = Enabled }, + } + }, }, - }, + } }; + + if (ButtonText == default) + { + text.Padding = new MarginPadding { Right = 100 }; + button.Width = 90; + } + else + { + text.Width = 0.55f; + text.Padding = new MarginPadding { Right = 10 }; + button.RelativeSizeAxes = Axes.X; + button.Width = 0.45f; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Enabled.BindValueChanged(_ => updateState(), true); } protected override bool OnHover(HoverEvent e) @@ -98,12 +154,29 @@ protected override void OnHoverLost(HoverLostEvent e) updateState(); } + protected override bool OnClick(ClickEvent e) + { + if (Enabled.Value) + { + background.Flash(); + button.TriggerClick(); + } + + return true; + } + private void updateState() { - BorderThickness = IsHovered ? 2 : 0; + text.Colour = Enabled.Value ? colourProvider.Content1 : colourProvider.Background1; - if (IsHovered) - BorderColour = colourProvider.Light4; + if (!Enabled.Value) + background.VisualStyle = VisualStyle.Disabled; + else if (IsHovered) + background.VisualStyle = VisualStyle.Hovered; + else + background.VisualStyle = VisualStyle.Normal; + + // TODO: Support BackgroundColour? } public partial class Button : OsuButton @@ -125,6 +198,8 @@ public override Color4 BackgroundColour } } + public IconUsage Icon { get; init; } + [BackgroundDependencyLoader] private void load(OverlayColourProvider overlayColourProvider) { @@ -135,7 +210,7 @@ private void load(OverlayColourProvider overlayColourProvider) { Add(new SpriteIcon { - Icon = FontAwesome.Solid.ChevronRight, + Icon = Icon, Size = new Vector2(16), Shadow = true, Anchor = Anchor.Centre, diff --git a/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs index d4cd86010fc2..e15372d18ba7 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs @@ -1,25 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Localisation; using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterfaceV2 { - public partial class FormCheckBox : CompositeDrawable, IHasCurrentValue + public partial class FormCheckBox : CompositeDrawable, IHasCurrentValue, IFormControl { public Bindable Current { @@ -39,54 +35,47 @@ public Bindable Current /// public LocalisableString HintText { get; init; } - private Box background = null!; + private FormControlBackground background = null!; private FormFieldCaption caption = null!; - private OsuSpriteText text = null!; - private Nub checkbox = null!; - private Sample? sampleChecked; - private Sample? sampleUnchecked; + private SwitchButton switchButton = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load() { RelativeSizeAxes = Axes.X; - Height = 50; - - Masking = true; - CornerRadius = 5; - CornerExponent = 2.5f; + AutoSizeAxes = Axes.Y; InternalChildren = new Drawable[] { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, + background = new FormControlBackground(), new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Padding = new MarginPadding(9), Children = new Drawable[] { - caption = new FormFieldCaption - { - Caption = Caption, - TooltipText = HintText, - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - }, - text = new OsuSpriteText + new Container { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = SwitchButton.WIDTH + 5 }, + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Caption = Caption, + TooltipText = HintText, + }, + }, }, - checkbox = new Nub + switchButton = new SwitchButton { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -95,9 +84,6 @@ private void load(AudioManager audio) }, }, }; - - sampleChecked = audio.Samples.Get(@"UI/check-on"); - sampleUnchecked = audio.Samples.Get(@"UI/check-off"); } protected override void LoadComplete() @@ -107,20 +93,13 @@ protected override void LoadComplete() current.BindValueChanged(_ => { updateState(); - playSamples(); - background.FlashColour(ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark2), 800, Easing.OutQuint); + background.Flash(); + + ValueChanged?.Invoke(); }); current.BindDisabledChanged(_ => updateState(), true); } - private void playSamples() - { - if (Current.Value) - sampleChecked?.Play(); - else - sampleUnchecked?.Play(); - } - protected override bool OnHover(HoverEvent e) { updateState(); @@ -135,27 +114,32 @@ protected override void OnHoverLost(HoverLostEvent e) protected override bool OnClick(ClickEvent e) { - if (!Current.Disabled) - Current.Value = !Current.Value; + switchButton.TriggerClick(); return true; } private void updateState() { - background.Colour = Current.Disabled ? colourProvider.Background4 : colourProvider.Background5; - caption.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; - checkbox.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; - text.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + caption.Colour = Current.Disabled ? colourProvider.Background1 : colourProvider.Content2; - text.Text = Current.Value ? CommonStrings.Enabled : CommonStrings.Disabled; + if (IsDisabled) + background.VisualStyle = VisualStyle.Disabled; + else if (IsHovered) + background.VisualStyle = VisualStyle.Hovered; + else + background.VisualStyle = VisualStyle.Normal; + } - if (!Current.Disabled) - { - BorderThickness = IsHovered ? 2 : 0; + public IEnumerable FilterTerms => Caption.Yield(); - if (IsHovered) - BorderColour = colourProvider.Light4; - } - } + public event Action? ValueChanged; + + public bool IsDefault => Current.IsDefault; + + public void SetDefault() => Current.SetDefault(); + + public bool IsDisabled => Current.Disabled; + + public float MainDrawHeight => DrawHeight; } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs index a0348fa27a62..0eb59415c047 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs @@ -33,7 +33,7 @@ public partial class FormColourPalette : CompositeDrawable public BindableBool CanAdd { get; } = new BindableBool(true); - private Box background = null!; + private FormControlBackground background = null!; private FormFieldCaption caption = null!; private FillFlowContainer flow = null!; private RoundedButton addButton = null!; @@ -47,16 +47,9 @@ private void load() RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Masking = true; - CornerRadius = 5; - InternalChildren = new Drawable[] { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, + background = new FormControlBackground(), new FillFlowContainer { RelativeSizeAxes = Axes.X, @@ -140,13 +133,12 @@ private void addNewColour() private void updateState() { - background.Colour = colourProvider.Background5; caption.Colour = colourProvider.Content2; - BorderThickness = IsHovered ? 2 : 0; - if (IsHovered) - BorderColour = colourProvider.Light4; + background.VisualStyle = VisualStyle.Hovered; + else + background.VisualStyle = VisualStyle.Normal; } private void updateColours() @@ -178,7 +170,8 @@ private void load() Size = new Vector2(70); Masking = true; - CornerRadius = 35; + CornerRadius = 10; + CornerExponent = 2.5f; Action = this.ShowPopover; Children = new Drawable[] diff --git a/osu.Game/Graphics/UserInterfaceV2/FormControlBackground.cs b/osu.Game/Graphics/UserInterfaceV2/FormControlBackground.cs new file mode 100644 index 000000000000..46042ea08c17 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormControlBackground.cs @@ -0,0 +1,125 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormControlBackground : CompositeDrawable + { + public const float CORNER_EXPONENT = 2.5f; + public const float BORDER_THICKNESS = 2.5f; + + private VisualStyle visualStyle; + + public VisualStyle VisualStyle + { + get => visualStyle; + set + { + visualStyle = value; + updateStyle(); + } + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private readonly Box box; + + private readonly HoverSounds sounds; + + public FormControlBackground() + { + RelativeSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 5; + + CornerExponent = CORNER_EXPONENT; + BorderThickness = BORDER_THICKNESS; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, + sounds = new HoverSounds(), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateStyle(); + FinishTransforms(true); + } + + public void Flash() + { + box.FlashColour(ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark2), 800, Easing.OutQuint); + } + + private void updateStyle() + { + sounds.Enabled.Value = visualStyle != VisualStyle.Disabled; + + ColourInfo colour; + ColourInfo borderColour; + + bool border = false; + + switch (visualStyle) + { + case VisualStyle.Normal: + colour = colourProvider.Background4.Darken(0.1f); + borderColour = colourProvider.Light4; + break; + + case VisualStyle.Disabled: + colour = colourProvider.Background4; + borderColour = colourProvider.Dark1; + break; + + case VisualStyle.Hovered: + colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); + borderColour = colourProvider.Light4; + border = true; + break; + + case VisualStyle.Focused: + colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); + border = true; + borderColour = colourProvider.Highlight1; + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + this.TransformTo(nameof(BorderColour), border ? borderColour : colour, 250, Easing.OutQuint); + + box.FadeColour(colour, 250, Easing.OutQuint); + } + } + + public enum VisualStyle + { + Normal, + Disabled, + Hovered, + Focused + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs index d47b9ac73de1..7ac305a500f5 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; @@ -13,11 +15,12 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 { - public partial class FormDropdown : OsuDropdown + public partial class FormDropdown : OsuDropdown, IFormControl { /// /// Caption describing this slider bar, displayed on top of the controls. @@ -27,10 +30,22 @@ public partial class FormDropdown : OsuDropdown /// /// Hint text containing an extended description of this slider bar, displayed in a tooltip when hovering the caption. /// - public LocalisableString HintText { get; init; } + public LocalisableString HintText + { + get => header.HintText; + set => header.HintText = value; + } + + /// + /// The maximum height of the dropdown's menu. + /// By default, this is set to 200px high. Set to to remove such limit. + /// + public float MaxHeight { get; set; } = 200; private FormDropdownHeader header = null!; + private const float header_menu_spacing = 5; + [BackgroundDependencyLoader] private void load() { @@ -40,12 +55,42 @@ private void load() header.HintText = HintText; } + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(_ => ValueChanged?.Invoke()); + } + + public virtual IEnumerable FilterTerms + { + get + { + yield return Caption; + + foreach (var item in MenuItems) + yield return item.Text.Value; + } + } + + public event Action? ValueChanged; + + public bool IsDefault => Current.IsDefault; + + public void SetDefault() => Current.SetDefault(); + + public bool IsDisabled => Current.Disabled; + + public float MainDrawHeight => header.DrawHeight; + protected override DropdownHeader CreateHeader() => header = new FormDropdownHeader { Dropdown = this, }; - protected override DropdownMenu CreateMenu() => new FormDropdownMenu(); + protected override DropdownMenu CreateMenu() => new FormDropdownMenu + { + MaxHeight = MaxHeight, + }; private partial class FormDropdownHeader : DropdownHeader { @@ -98,6 +143,7 @@ protected override LocalisableString Label private FormFieldCaption caption = null!; private OsuSpriteText label = null!; private SpriteIcon chevron = null!; + private FormControlBackground background = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -105,41 +151,54 @@ protected override LocalisableString Label [BackgroundDependencyLoader] private void load() { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.None; - Height = 50; - Masking = true; CornerRadius = 5; - Foreground.AutoSizeAxes = Axes.None; - Foreground.RelativeSizeAxes = Axes.Both; - Foreground.Padding = new MarginPadding(9); + // We use our own background for more control. + Background.Alpha = 0; + Foreground.Children = new Drawable[] { - caption = new FormFieldCaption - { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Caption = Caption, - TooltipText = HintText, - }, - label = new OsuSpriteText + background = new FormControlBackground(), + new Container { RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - }, - chevron = new SpriteIcon - { - Icon = FontAwesome.Solid.ChevronDown, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Size = new Vector2(16), + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(9), + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 4), + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Caption = Caption, + TooltipText = HintText, + }, + label = new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Right = 25 }, + AlwaysPresent = true, + }, + } + }, + chevron = new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronDown, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Size = new Vector2(16), + Margin = new MarginPadding { Right = 5 }, + }, + } }, }; - - AddInternal(new HoverClickSounds()); } protected override void LoadComplete() @@ -173,37 +232,33 @@ protected override void OnHoverLost(HoverLostEvent e) private void updateState() { - label.Alpha = string.IsNullOrEmpty(SearchBar.SearchTerm.Value) ? 1 : 0; - - caption.Colour = Dropdown.Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; - label.Colour = Dropdown.Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; - chevron.Colour = Dropdown.Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + caption.Colour = Dropdown.Current.Disabled ? colourProvider.Background1 : colourProvider.Content2; + label.Colour = Dropdown.Current.Disabled ? colourProvider.Background1 : colourProvider.Content1; + chevron.Colour = Dropdown.Current.Disabled ? colourProvider.Background1 : colourProvider.Content1; DisabledColour = Colour4.White; bool dropdownOpen = Dropdown.Menu.State == MenuState.Open; - if (!Dropdown.Current.Disabled) - { - BorderThickness = IsHovered || dropdownOpen ? 2 : 0; - BorderColour = dropdownOpen ? colourProvider.Highlight1 : colourProvider.Light4; - - if (dropdownOpen) - Background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); - else if (IsHovered) - Background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); - else - Background.Colour = colourProvider.Background5; - } + if (dropdownOpen) + label.Alpha = AlwaysShowSearchBar || !string.IsNullOrEmpty(SearchBar.SearchTerm.Value) ? 0 : 1; else - { - Background.Colour = colourProvider.Background4; - } + label.Alpha = 1; + + if (Dropdown.Current.Disabled) + background.VisualStyle = VisualStyle.Disabled; + else if (dropdownOpen) + background.VisualStyle = VisualStyle.Focused; + else if (IsHovered) + background.VisualStyle = VisualStyle.Hovered; + else + background.VisualStyle = VisualStyle.Normal; } private void updateChevron() { bool open = Dropdown.Menu.State == MenuState.Open; chevron.ScaleTo(open ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); + chevron.MoveToY(open ? -chevron.DrawHeight : 0, 300, Easing.OutQuint); } } @@ -214,7 +269,10 @@ private partial class FormDropdownSearchBar : DropdownSearchBar protected override void PopIn() => this.FadeIn(); protected override void PopOut() => this.FadeOut(); - protected override TextBox CreateTextBox() => TextBox = new FormTextBox.InnerTextBox(); + protected override TextBox CreateTextBox() => TextBox = new FormTextBox.InnerTextBox + { + PlaceholderText = HomeStrings.SearchPlaceholder, + }; [BackgroundDependencyLoader] private void load() @@ -222,7 +280,7 @@ private void load() TextBox.Anchor = Anchor.BottomLeft; TextBox.Origin = Anchor.BottomLeft; TextBox.RelativeSizeAxes = Axes.X; - TextBox.Margin = new MarginPadding(9); + Padding = new MarginPadding { Left = 9, Bottom = 9, Right = 34 }; } } @@ -232,11 +290,27 @@ private partial class FormDropdownMenu : OsuDropdownMenu private void load(OverlayColourProvider colourProvider) { ItemsContainer.Padding = new MarginPadding(9); - Margin = new MarginPadding { Top = 5 }; - MaskingContainer.BorderThickness = 2; + MaskingContainer.BorderThickness = FormControlBackground.BORDER_THICKNESS; + MaskingContainer.CornerExponent = FormControlBackground.CORNER_EXPONENT; MaskingContainer.BorderColour = colourProvider.Highlight1; } + + protected override void AnimateOpen() + { + base.AnimateOpen(); + + this.TransformTo(nameof(Margin), new MarginPadding + { + Top = header_menu_spacing, + }, 300, Easing.OutQuint); + } + + protected override void AnimateClose() + { + base.AnimateClose(); + this.TransformTo(nameof(Margin), new MarginPadding(), 300, Easing.OutQuint); + } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs b/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs index 75c27618e9e4..958d6e9e0df4 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs @@ -2,19 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 { public partial class FormFieldCaption : CompositeDrawable, IHasTooltip { + private OsuTextFlowContainer textFlow = null!; + private LocalisableString caption; public LocalisableString Caption @@ -24,45 +25,60 @@ public LocalisableString Caption { caption = value; - if (captionText.IsNotNull()) - captionText.Text = value; + if (IsLoaded) + updateDisplay(); } } - private OsuSpriteText captionText = null!; + private LocalisableString tooltipText; - public LocalisableString TooltipText { get; set; } + public LocalisableString TooltipText + { + get => tooltipText; + set + { + tooltipText = value; + + if (IsLoaded) + updateDisplay(); + } + } [BackgroundDependencyLoader] private void load() { - AutoSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; - InternalChild = new FillFlowContainer + InternalChild = textFlow = new OsuTextFlowContainer(t => t.Font = OsuFont.Style.Caption1) { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - Children = new Drawable[] - { - captionText = new OsuSpriteText - { - Text = caption, - Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Alpha = TooltipText == default ? 0 : 1, - Size = new Vector2(10), - Icon = FontAwesome.Solid.QuestionCircle, - Margin = new MarginPadding { Top = 1, }, - } - }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDisplay(); + } + + private void updateDisplay() + { + textFlow.Text = caption; + + if (TooltipText != default) + { + textFlow.AddArbitraryDrawable(new SpriteIcon + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Size = new Vector2(10), + Icon = FontAwesome.Solid.QuestionCircle, + Margin = new MarginPadding { Left = 5 }, + Y = 1f, + }); + } + } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs index 5fdf453fc4c2..d4edcd2ff377 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs @@ -11,7 +11,6 @@ using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; @@ -70,7 +69,7 @@ public Bindable Current public Container PreviewContainer { get; private set; } = null!; - private Box background = null!; + private FormControlBackground background = null!; private FormFieldCaption caption = null!; private OsuSpriteText placeholderText = null!; @@ -93,16 +92,9 @@ private void load() RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Masking = true; - CornerRadius = 5; - InternalChildren = new Drawable[] { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, + background = new FormControlBackground(), PreviewContainer = new Container { RelativeSizeAxes = Axes.X, @@ -117,34 +109,46 @@ private void load() new Container { RelativeSizeAxes = Axes.X, - Height = 50, + AutoSizeAxes = Axes.Y, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Padding = new MarginPadding(9), Children = new Drawable[] { - caption = new FormFieldCaption - { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Caption = Caption, - TooltipText = HintText, - }, - placeholderText = new OsuSpriteText + new FillFlowContainer { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, - Width = 1, - Text = PlaceholderText, - Colour = colourProvider.Foreground1, - }, - filenameText = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Width = 1, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Caption = Caption, + TooltipText = HintText, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + placeholderText = new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Width = 1, + Text = PlaceholderText, + Colour = colourProvider.Foreground1, + }, + filenameText = new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Width = 1, + }, + } + } + }, }, new SpriteIcon { @@ -182,7 +186,7 @@ private void onFileSelected() initialChooserPath = Current.Value?.DirectoryName; placeholderText.Alpha = Current.Value == null ? 1 : 0; filenameText.Text = Current.Value?.Name ?? string.Empty; - background.FlashColour(ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark2), 800, Easing.OutQuint); + background.Flash(); } protected override bool OnClick(ClickEvent e) @@ -208,22 +212,14 @@ private void updateState() caption.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; filenameText.Colour = Current.Disabled || Current.Value == null ? colourProvider.Foreground1 : colourProvider.Content1; - if (!Current.Disabled) - { - BorderThickness = IsHovered || popoverState.Value == Visibility.Visible ? 2 : 0; - BorderColour = popoverState.Value == Visibility.Visible ? colourProvider.Highlight1 : colourProvider.Light4; - - if (popoverState.Value == Visibility.Visible) - background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); - else if (IsHovered) - background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); - else - background.Colour = colourProvider.Background5; - } + if (Current.Disabled) + background.VisualStyle = VisualStyle.Disabled; + else if (popoverState.Value == Visibility.Visible) + background.VisualStyle = VisualStyle.Focused; + else if (IsHovered) + background.VisualStyle = VisualStyle.Hovered; else - { - background.Colour = colourProvider.Background4; - } + background.VisualStyle = VisualStyle.Normal; } protected override void Dispose(bool isDisposing) @@ -242,7 +238,8 @@ Task ICanAcceptFiles.Import(params string[] paths) Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); - protected virtual FileChooserPopover CreatePopover(string[] handledExtensions, Bindable current, string? chooserPath) => new FileChooserPopover(handledExtensions, current, chooserPath); + protected virtual FileChooserPopover CreatePopover(string[] handledExtensions, Bindable current, string? chooserPath) => + new FileChooserPopover(handledExtensions, current, chooserPath); public Popover GetPopover() { @@ -252,7 +249,7 @@ public Popover GetPopover() return popover; } - protected partial class FileChooserPopover : OsuPopover + public partial class FileChooserPopover : OsuPopover { protected override string PopInSampleName => "UI/overlay-big-pop-in"; protected override string PopOutSampleName => "UI/overlay-big-pop-out"; diff --git a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs index 1304c298fb8c..7adf6913090b 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.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.Globalization; using System.Numerics; using osu.Framework.Allocation; @@ -16,13 +17,17 @@ using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Game.Extensions; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Overlays; +using osuTK.Graphics; +using Vector2 = osuTK.Vector2; namespace osu.Game.Graphics.UserInterfaceV2 { - public partial class FormSliderBar : CompositeDrawable, IHasCurrentValue + public partial class FormSliderBar : CompositeDrawable, IHasCurrentValue, IFormControl where T : struct, INumber, IMinMaxValue { public Bindable Current @@ -31,13 +36,20 @@ public Bindable Current set { current.Current = value; + + // the above `Current` set could have disabled the instantaneous bindable too, + // but we still need to copy out `Default` manually, + // so lift that disable for a second and then restore it + currentNumberInstantaneous.Disabled = false; currentNumberInstantaneous.Default = current.Default; + currentNumberInstantaneous.Disabled = current.Disabled; } } private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(); private readonly BindableNumber currentNumberInstantaneous = new BindableNumber(); + private readonly InnerSlider slider; /// /// Whether changes to the value should instantaneously transfer to outside bindables. @@ -58,10 +70,22 @@ public CompositeDrawable? TabbableContentContainer } } + private LocalisableString caption; + /// /// Caption describing this slider bar, displayed on top of the controls. /// - public LocalisableString Caption { get; init; } + public LocalisableString Caption + { + get => caption; + set + { + caption = value; + + if (IsLoaded) + captionText.Caption = value; + } + } /// /// Hint text containing an extended description of this slider bar, displayed in a tooltip when hovering the caption. @@ -71,13 +95,38 @@ public CompositeDrawable? TabbableContentContainer /// /// A custom step value for each key press which actuates a change on this control. /// - public float KeyboardStep { get; init; } + public float KeyboardStep + { + get => slider.KeyboardStep; + set => slider.KeyboardStep = value; + } - private Box background = null!; + /// + /// Whether to format the tooltip as a percentage or the actual value. + /// + public bool DisplayAsPercentage { get; init; } + + /// + /// Whether sound effects should play when adjusting this slider. + /// + public bool PlaySamplesOnAdjust { get; init; } = true; + + /// + /// The string formatting function to use for the value label. + /// + public Func LabelFormat { get; init; } + + /// + /// The string formatting function to use for the slider's tooltip text. + /// If not provided, is used. + /// + public Func TooltipFormat { get; init; } + + private FormControlBackground background = null!; private Box flashLayer = null!; private FormTextBox.InnerTextBox textBox = null!; - private InnerSlider slider = null!; - private FormFieldCaption caption = null!; + private OsuSpriteText valueLabel = null!; + private FormFieldCaption captionText = null!; private IFocusManager focusManager = null!; [Resolved] @@ -85,22 +134,77 @@ public CompositeDrawable? TabbableContentContainer private readonly Bindable currentLanguage = new Bindable(); + public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true; + + public FormSliderBar() + { + LabelFormat ??= defaultLabelFormat; + TooltipFormat ??= v => LabelFormat(v); + + // the reason why this slider is created in constructor rather than in BDL like the rest of drawable hierarchy is as follows: + // `SliderBar` (the base framework class for all sliders) also does its `Current` initialisation in its ctor. + // if that precedent is not followed, it is possible to run into a crippling issue + // when a `FormSliderBar` instance is on a screen and said screen is exited before said instance's `LoadComplete()` is invoked. + // in that case, the screen exit will unbind the `InnerSlider`'s internal bindings & value change callbacks: + // https://github.com/ppy/osu-framework/blob/23ac694fa2c342ce39f563c8a1b975119249d5e9/osu.Framework/Screens/ScreenStack.cs#L353 + // the callbacks are supposed to propagate `{Min,Max}Value` from `Current` to its internal `currentNumberInstantaneous` bindable: + // https://github.com/ppy/osu-framework/blob/64624795b0816261dfc5e930e1d9b9ec7e8bb8c5/osu.Framework/Graphics/UserInterface/SliderBar.cs#L62-L63 + // thus, the callbacks getting unbound by the screen exit prevents `{Min,Max}Value` from ever correctly propagating, which finally causes a crash at + // https://github.com/ppy/osu-framework/blob/64624795b0816261dfc5e930e1d9b9ec7e8bb8c5/osu.Framework/Graphics/UserInterface/SliderBar.cs#L112 -> + // https://github.com/ppy/osu-framework/blob/64624795b0816261dfc5e930e1d9b9ec7e8bb8c5/osu.Framework/Graphics/UserInterface/SliderBar.cs#L88-L92. + // moving the slider creation & binding to constructor does little to fix the issue other than to make it less likely to be hit. + slider = new InnerSlider + { + Current = currentNumberInstantaneous, + OnCommit = () => current.Value = currentNumberInstantaneous.Value, + TooltipFormat = s => TooltipFormat(s), + DisplayAsPercentage = DisplayAsPercentage, + PlaySamplesOnAdjust = PlaySamplesOnAdjust, + ResetToDefault = () => + { + if (!IsDisabled) + SetDefault(); + } + }; + + current.ValueChanged += e => + { + currentNumberInstantaneous.Value = e.NewValue; + ValueChanged?.Invoke(); + }; + + current.MinValueChanged += v => currentNumberInstantaneous.MinValue = v; + current.MaxValueChanged += v => currentNumberInstantaneous.MaxValue = v; + current.PrecisionChanged += v => currentNumberInstantaneous.Precision = v; + current.DisabledChanged += disabled => + { + if (disabled) + { + // revert any changes before disabling to make sure we are in a consistent state. + currentNumberInstantaneous.Value = current.Value; + } + + currentNumberInstantaneous.Disabled = disabled; + if (IsLoaded) + updateState(); + }; + + current.CopyTo(currentNumberInstantaneous); + } + [BackgroundDependencyLoader] private void load(OsuColour colours, OsuGame? game) { RelativeSizeAxes = Axes.X; - Height = 50; + AutoSizeAxes = Axes.Y; Masking = true; CornerRadius = 5; + CornerExponent = 2.5f; InternalChildren = new Drawable[] { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, + background = new FormControlBackground(), flashLayer = new Box { RelativeSizeAxes = Axes.Both, @@ -108,47 +212,72 @@ private void load(OsuColour colours, OsuGame? game) }, new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Padding = new MarginPadding { - Vertical = 9, + Vertical = 5, Left = 9, Right = 5, }, Children = new Drawable[] { - caption = new FormFieldCaption - { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Caption = Caption, - TooltipText = HintText, - }, - textBox = new FormNumberBox.InnerNumberBox(allowDecimals: true) + new FillFlowContainer { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), Width = 0.5f, - CommitOnFocusLost = true, - SelectAllOnFocus = true, - OnInputError = () => + Padding = new MarginPadding { - flashLayer.Colour = ColourInfo.GradientVertical(colours.Red3.Opacity(0), colours.Red3); - flashLayer.FadeOutFromOne(200, Easing.OutQuint); + Right = 10, + Vertical = 4, + }, + Children = new Drawable[] + { + captionText = new FormFieldCaption + { + TooltipText = HintText, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + textBox = new FormNumberBox.InnerNumberBox(allowDecimals: true) + { + RelativeSizeAxes = Axes.X, + // the textbox is hidden when the control is unfocused, + // but clicking on the label should reach the textbox, + // therefore make it always present. + AlwaysPresent = true, + CommitOnFocusLost = true, + SelectAllOnFocus = true, + OnInputError = () => + { + flashLayer.Colour = ColourInfo.GradientVertical(colours.Red3.Opacity(0), colours.Red3); + flashLayer.FadeOutFromOne(200, Easing.OutQuint); + }, + TabbableContentContainer = tabbableContentContainer, + }, + valueLabel = new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Right = 5 }, + }, + }, + }, }, - TabbableContentContainer = tabbableContentContainer, }, - slider = new InnerSlider + slider.With(s => { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.X, - Width = 0.5f, - KeyboardStep = KeyboardStep, - Current = currentNumberInstantaneous, - OnCommit = () => current.Value = currentNumberInstantaneous.Value, - } + s.Anchor = Anchor.CentreRight; + s.Origin = Anchor.CentreRight; + s.RelativeSizeAxes = Axes.X; + s.Width = 0.5f; + }) }, }, }; @@ -161,6 +290,8 @@ protected override void LoadComplete() { base.LoadComplete(); + captionText.Caption = caption; + focusManager = GetContainingFocusManager()!; textBox.Focused.BindValueChanged(_ => updateState()); @@ -170,23 +301,8 @@ protected override void LoadComplete() slider.IsDragging.BindValueChanged(_ => updateState()); slider.Focused.BindValueChanged(_ => updateState()); - current.ValueChanged += e => currentNumberInstantaneous.Value = e.NewValue; - current.MinValueChanged += v => currentNumberInstantaneous.MinValue = v; - current.MaxValueChanged += v => currentNumberInstantaneous.MaxValue = v; - current.PrecisionChanged += v => currentNumberInstantaneous.Precision = v; - current.DisabledChanged += disabled => - { - if (disabled) - { - // revert any changes before disabling to make sure we are in a consistent state. - currentNumberInstantaneous.Value = current.Value; - } - - currentNumberInstantaneous.Disabled = disabled; - }; - - current.CopyTo(currentNumberInstantaneous); currentLanguage.BindValueChanged(_ => Schedule(updateValueDisplay)); + currentNumberInstantaneous.BindDisabledChanged(_ => updateState()); currentNumberInstantaneous.BindValueChanged(e => { if (!TransferValueOnCommit) @@ -211,8 +327,7 @@ private void textCommitted(TextBox t, bool isNew) currentNumberInstantaneous.TriggerChange(); current.Value = currentNumberInstantaneous.Value; - flashLayer.Colour = ColourInfo.GradientVertical(colourProvider.Dark2.Opacity(0), colourProvider.Dark2); - flashLayer.FadeOutFromOne(800, Easing.OutQuint); + background.Flash(); } private void tryUpdateSliderFromTextBox() @@ -228,7 +343,11 @@ private void tryUpdateSliderFromTextBox() break; case Bindable bindableDouble: - bindableDouble.Value = double.Parse(textBox.Current.Value); + bindableDouble.Value = double.Parse(textBox.Current.Value) / (DisplayAsPercentage ? 100 : 1); + break; + + case Bindable bindableFloat: + bindableFloat.Value = float.Parse(textBox.Current.Value) / (DisplayAsPercentage ? 100 : 1); break; default: @@ -259,7 +378,8 @@ protected override void OnHoverLost(HoverLostEvent e) protected override bool OnClick(ClickEvent e) { - focusManager.ChangeFocus(textBox); + if (!Current.Disabled) + focusManager.ChangeFocus(textBox); return true; } @@ -267,41 +387,76 @@ private void updateState() { bool childHasFocus = slider.Focused.Value || textBox.Focused.Value; - textBox.Alpha = 1; - - background.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background4 : colourProvider.Background5; - caption.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; - textBox.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + textBox.ReadOnly = currentNumberInstantaneous.Disabled; + textBox.Alpha = textBox.Focused.Value ? 1 : 0; + valueLabel.Alpha = textBox.Focused.Value ? 0 : 1; - BorderThickness = childHasFocus || IsHovered || slider.IsDragging.Value ? 2 : 0; - BorderColour = childHasFocus ? colourProvider.Highlight1 : colourProvider.Light4; + captionText.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background1 : colourProvider.Content2; + textBox.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background1 : colourProvider.Content1; + valueLabel.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background1 : colourProvider.Content1; - if (childHasFocus) - background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); + if (Current.Disabled) + background.VisualStyle = VisualStyle.Disabled; + else if (childHasFocus) + background.VisualStyle = VisualStyle.Focused; else if (IsHovered || slider.IsDragging.Value) - background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); + background.VisualStyle = VisualStyle.Hovered; else - background.Colour = colourProvider.Background5; + background.VisualStyle = VisualStyle.Normal; } private void updateValueDisplay() { if (updatingFromTextBox) return; - textBox.Text = slider.GetDisplayableValue(currentNumberInstantaneous.Value).ToString(); + if (DisplayAsPercentage) + { + double floatValue = double.CreateTruncating(currentNumberInstantaneous.Value); + + // if `DisplayAsPercentage` is true and `T` is not `int`, then `Current` / `currentNumberInstantaneous` are in the range of [0,1]. + // in the text box, we want to show the percentage in the range of [0,100], but without the percentage sign. + // the reason we don't want a percentage sign is that `TextBox`es with numerical `TextInputType`s + // have framework-side limitations on which characters they accept and they won't accept a percentage sign. + // + // therefore, the instantaneous value needs to be multiplied by 100 if it's not `int`, so that `ToStandardFormattedString()`, + // which is called *intentionally* without `asPercentage: true` specified as to not emit the percentage sign, spits out the correct number. + // + // additionally note that `ToStandardFormattedString()`, when called with `asPercentage: true` specified, does the *inverse* of this, + // which is that it brings the formatted number *into* the [0,1] range, + // because .NET number formatting *automatically* multiplies the formatted number by 100 when it is told to stringify a number as percentage + // (https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings#the--custom-specifier-3). + // it's all very confusing. + if (currentNumberInstantaneous.Value is not int) + floatValue *= 100; + + textBox.Text = floatValue.ToStandardFormattedString(Math.Max(0, OsuSliderBar.MAX_DECIMAL_DIGITS - 2)); + } + else + textBox.Text = currentNumberInstantaneous.Value.ToStandardFormattedString(OsuSliderBar.MAX_DECIMAL_DIGITS); + + valueLabel.Text = LabelFormat(currentNumberInstantaneous.Value); } - private partial class InnerSlider : OsuSliderBar + private LocalisableString defaultLabelFormat(T value) => currentNumberInstantaneous.Value.ToStandardFormattedString(OsuSliderBar.MAX_DECIMAL_DIGITS, DisplayAsPercentage); + + public partial class InnerSlider : OsuSliderBar { public BindableBool Focused { get; } = new BindableBool(); - public BindableBool IsDragging { get; set; } = new BindableBool(); - public Action? OnCommit { get; set; } + public BindableBool IsDragging { get; } = new BindableBool(); + + public Action? ResetToDefault { get; init; } + + public Action? OnCommit { get; init; } + + public sealed override LocalisableString TooltipText => base.TooltipText; + + public required Func TooltipFormat { get; init; } private Box leftBox = null!; private Box rightBox = null!; - private Circle nub = null!; - private const float nub_width = 10; + private InnerSliderNub nub = null!; + public const float NUB_WIDTH = 10; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -311,7 +466,7 @@ private void load() { Height = 40; RelativeSizeAxes = Axes.X; - RangePadding = nub_width / 2; + RangePadding = NUB_WIDTH / 2; Children = new Drawable[] { @@ -340,22 +495,20 @@ private void load() { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Horizontal = RangePadding, }, - Child = nub = new Circle + Child = nub = new InnerSliderNub { - Width = nub_width, - RelativeSizeAxes = Axes.Y, - RelativePositionAxes = Axes.X, - Origin = Anchor.TopCentre, + ResetToDefault = ResetToDefault, } }, - new HoverClickSounds() }; } protected override void LoadComplete() { base.LoadComplete(); - updateState(); + + Current.BindDisabledChanged(_ => updateState(), true); + FinishTransforms(true); } protected override void UpdateAfterChildren() @@ -408,14 +561,29 @@ protected override void OnFocusLost(FocusLostEvent e) private void updateState() { - rightBox.Colour = colourProvider.Background6; - leftBox.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1.Opacity(0.5f) : colourProvider.Dark2; - nub.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1 : colourProvider.Light4; + rightBox.Colour = colourProvider.Background5; + + Color4 leftColour = colourProvider.Light4; + Color4 nubColour; + + if (IsHovered || HasFocus || IsDragged) + nubColour = colourProvider.Highlight1; + else + nubColour = colourProvider.Highlight1.Darken(0.1f); + + if (Current.Disabled) + { + nubColour = nubColour.Darken(0.4f); + leftColour = leftColour.Darken(0.4f); + } + + leftBox.FadeColour(leftColour, 250, Easing.OutQuint); + nub.FadeColour(nubColour, 250, Easing.OutQuint); } protected override void UpdateValue(float value) { - nub.MoveToX(value, 200, Easing.OutPow10); + nub.MoveToX(value, 250, Easing.OutElasticQuarter); } protected override bool Commit() @@ -427,6 +595,43 @@ protected override bool Commit() return result; } + + protected sealed override LocalisableString GetTooltipText(T value) => TooltipFormat(value); + } + + public partial class InnerSliderNub : Circle + { + public Action? ResetToDefault { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + CornerExponent = 2.5f; + Width = InnerSlider.NUB_WIDTH; + RelativeSizeAxes = Axes.Y; + RelativePositionAxes = Axes.X; + Origin = Anchor.TopCentre; + } + + protected override bool OnClick(ClickEvent e) => true; // must be handled for double click handler to ever fire + + protected override bool OnDoubleClick(DoubleClickEvent e) + { + ResetToDefault?.Invoke(); + return true; + } } + + public IEnumerable FilterTerms => new[] { Caption, HintText }; + + public event Action? ValueChanged; + + public bool IsDefault => Current.IsDefault; + + public void SetDefault() => Current.SetDefault(); + + public bool IsDisabled => Current.Disabled; + + public float MainDrawHeight => DrawHeight; } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs index 973419310c0c..7b97c8baf3ae 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -17,10 +19,11 @@ using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 { - public partial class FormTextBox : CompositeDrawable, IHasCurrentValue + public partial class FormTextBox : CompositeDrawable, IHasCurrentValue, IFormControl { public Bindable Current { @@ -74,7 +77,7 @@ public CompositeDrawable? TabbableContentContainer /// public LocalisableString PlaceholderText { get; init; } - private Box background = null!; + private FormControlBackground background = null!; private Box flashLayer = null!; private InnerTextBox textBox = null!; private FormFieldCaption caption = null!; @@ -87,28 +90,22 @@ public CompositeDrawable? TabbableContentContainer private void load(OsuColour colours) { RelativeSizeAxes = Axes.X; - Height = 50; - - Masking = true; - CornerRadius = 5; - CornerExponent = 2.5f; + AutoSizeAxes = Axes.Y; InternalChildren = new Drawable[] { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, + background = new FormControlBackground(), flashLayer = new Box { RelativeSizeAxes = Axes.Both, Colour = Colour4.Transparent, }, - new Container + new FillFlowContainer { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Padding = new MarginPadding(9), + Spacing = new Vector2(0, 4), Children = new Drawable[] { caption = new FormFieldCaption @@ -120,8 +117,6 @@ private void load(OsuColour colours) }, textBox = CreateTextBox().With(t => { - t.Anchor = Anchor.BottomRight; - t.Origin = Anchor.BottomRight; t.RelativeSizeAxes = Axes.X; t.Width = 1; t.PlaceholderText = PlaceholderText; @@ -157,6 +152,8 @@ protected override void LoadComplete() focusManager = GetContainingFocusManager()!; textBox.Focused.BindValueChanged(_ => updateState()); + + current.BindValueChanged(_ => ValueChanged?.Invoke()); current.BindDisabledChanged(_ => updateState(), true); } @@ -185,26 +182,17 @@ private void updateState() textBox.ReadOnly = disabled; textBox.Alpha = 1; - caption.Colour = disabled ? colourProvider.Foreground1 : colourProvider.Content2; + caption.Colour = disabled ? colourProvider.Background1 : colourProvider.Content2; textBox.Colour = disabled ? colourProvider.Foreground1 : colourProvider.Content1; - if (!disabled) - { - BorderThickness = IsHovered || textBox.Focused.Value ? 2 : 0; - BorderColour = textBox.Focused.Value ? colourProvider.Highlight1 : colourProvider.Light4; - - if (textBox.Focused.Value) - background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); - else if (IsHovered) - background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); - else - background.Colour = colourProvider.Background5; - } + if (Current.Disabled) + background.VisualStyle = VisualStyle.Disabled; + else if (textBox.Focused.Value) + background.VisualStyle = VisualStyle.Focused; + else if (IsHovered) + background.VisualStyle = VisualStyle.Hovered; else - { - BorderThickness = 0; - background.Colour = colourProvider.Background4; - } + background.VisualStyle = VisualStyle.Normal; } internal partial class InnerTextBox : OsuTextBox @@ -215,12 +203,16 @@ internal partial class InnerTextBox : OsuTextBox protected override float LeftRightPadding => 0; + public InnerTextBox() + { + DrawBorder = false; + } + [BackgroundDependencyLoader] private void load() { Height = 16; TextContainer.Height = 1; - Masking = false; BackgroundUnfocused = BackgroundFocused = BackgroundCommit = Colour4.Transparent; } @@ -247,5 +239,17 @@ protected override void NotifyInputError() OnInputError?.Invoke(); } } + + public event Action? ValueChanged; + + public bool IsDefault => current.IsDefault; + + public void SetDefault() => current.SetDefault(); + + public bool IsDisabled => current.Disabled; + + public IEnumerable FilterTerms => Caption.Yield(); + + public float MainDrawHeight => DrawHeight; } } diff --git a/osu.Game/Graphics/UserInterfaceV2/IFormControl.cs b/osu.Game/Graphics/UserInterfaceV2/IFormControl.cs new file mode 100644 index 000000000000..4c59cc323595 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/IFormControl.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + /// + /// Represents an interface for all form controls. + /// + public interface IFormControl : IDrawable, IHasFilterTerms + { + /// + /// Invoked when the value of the control has changed. + /// + event Action ValueChanged; + + /// + /// Whether the value of this control is in a default state. + /// + bool IsDefault { get; } + + /// + /// If enabled, resets the control to its default state. + /// + void SetDefault(); + + /// + /// Whether the control is currently disabled. + /// + bool IsDisabled { get; } + + /// + /// The height of the main part of the control (when not expanded). + /// This is used to attach external elements. + /// + float MainDrawHeight { get; } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs index dbbae390a74f..5dd9db750614 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs @@ -9,8 +9,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 { public partial class LabelledDropdown : LabelledComponent, TItem> { - public LabelledDropdown() - : base(true) + public LabelledDropdown(bool padded) + : base(padded) { } @@ -20,12 +20,22 @@ public IEnumerable Items set => Component.Items = value; } + public float DropdownWidth + { + get => Component.Width; + set => Component.Width = value; + } + protected sealed override OsuDropdown CreateComponent() => CreateDropdown().With(d => { d.RelativeSizeAxes = Axes.X; - d.Width = 0.5f; }); - protected virtual OsuDropdown CreateDropdown() => new OsuDropdown(); + protected virtual OsuDropdown CreateDropdown() => new Dropdown(); + + private partial class Dropdown : OsuDropdown + { + protected override DropdownMenu CreateMenu() => base.CreateMenu().With(menu => menu.MaxHeight = 200); + } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs index 9c2c8397ed71..5658175804ab 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs @@ -9,6 +9,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 public partial class LabelledEnumDropdown : LabelledDropdown where TEnum : struct, Enum { + public LabelledEnumDropdown(bool padded) + : base(padded) + { + } + protected override OsuDropdown CreateDropdown() => new OsuEnumDropdown(); } } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs index 65ffdcaa5bbc..3597af26cd38 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2.FileSelection; using osu.Game.Overlays; @@ -67,7 +68,7 @@ private void load(OverlayColourProvider colourProvider) protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); - protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string? displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); + protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, LocalisableString? displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); protected override void NotifySelectionError() => this.FlashColour(Colour4.Red, 300); } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index addea5c4a9cd..449b66007c3c 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2.FileSelection; @@ -69,7 +70,7 @@ private void load(OverlayColourProvider colourProvider) protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); - protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string? displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); + protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, LocalisableString? displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); protected override DirectoryListingFile CreateFileItem(FileInfo file) => new OsuDirectoryListingFile(file); diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs index bf92f20526a3..faabb8029903 100644 --- a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -26,18 +26,6 @@ public partial class RoundedButton : OsuButton, IFilterable, IHasTooltip private Color4? triangleGradientSecondColour; - public override float Height - { - get => base.Height; - set - { - base.Height = value; - - if (IsLoaded) - updateCornerRadius(); - } - } - public override Color4 BackgroundColour { get => base.BackgroundColour; @@ -61,7 +49,10 @@ protected override void LoadComplete() { base.LoadComplete(); - updateCornerRadius(); + // This doesn't match the latest design spec (should be 5) but is an in-between that feels right to the eye + // until we move everything over to Form controls. + Content.CornerRadius = 10; + Content.CornerExponent = 2.5f; Add(Triangles = new TrianglesV2 { @@ -98,8 +89,6 @@ protected override void OnHoverLost(HoverLostEvent e) base.OnHoverLost(e); } - private void updateCornerRadius() => Content.CornerRadius = DrawHeight / 2; - public virtual IEnumerable FilterTerms => new[] { Text }; public bool MatchingFilter diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs deleted file mode 100644 index 2fbe3ae89b1e..000000000000 --- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs +++ /dev/null @@ -1,151 +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.Numerics; -using System.Globalization; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Localisation; -using osu.Game.Overlays.Settings; -using osu.Game.Utils; -using Vector2 = osuTK.Vector2; - -namespace osu.Game.Graphics.UserInterfaceV2 -{ - public partial class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue - where T : struct, INumber, IMinMaxValue - { - /// - /// A custom step value for each key press which actuates a change on this control. - /// - public float KeyboardStep - { - get => slider.KeyboardStep; - set => slider.KeyboardStep = value; - } - - public Bindable Current - { - get => slider.Current; - set => slider.Current = value; - } - - public CompositeDrawable TabbableContentContainer - { - set => textBox.TabbableContentContainer = value; - } - - private bool instantaneous; - - /// - /// Whether changes to the slider should instantaneously transfer to the text box (and vice versa). - /// If , the transfer will happen on text box commit (explicit, or implicit via focus loss), or on slider drag end. - /// - public bool Instantaneous - { - get => instantaneous; - set - { - instantaneous = value; - slider.TransferValueOnCommit = !instantaneous; - } - } - - private readonly SettingsSlider slider; - private readonly LabelledTextBox textBox; - - public SliderWithTextBoxInput(LocalisableString labelText) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(20), - Children = new Drawable[] - { - textBox = new LabelledTextBox - { - Label = labelText, - SelectAllOnFocus = true, - }, - slider = new SettingsSlider - { - TransferValueOnCommit = true, - RelativeSizeAxes = Axes.X, - } - } - }, - }; - - textBox.OnCommit += textCommitted; - textBox.Current.BindValueChanged(textChanged); - - Current.BindValueChanged(updateTextBoxFromSlider, true); - } - - public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true; - - private bool updatingFromTextBox; - - private void textChanged(ValueChangedEvent change) - { - if (!instantaneous) return; - - tryUpdateSliderFromTextBox(); - } - - private void textCommitted(TextBox t, bool isNew) - { - tryUpdateSliderFromTextBox(); - - // If the attempted update above failed, restore text box to match the slider. - Current.TriggerChange(); - } - - private void tryUpdateSliderFromTextBox() - { - updatingFromTextBox = true; - - try - { - switch (slider.Current) - { - case Bindable bindableInt: - bindableInt.Value = int.Parse(textBox.Current.Value); - break; - - case Bindable bindableDouble: - bindableDouble.Value = double.Parse(textBox.Current.Value); - break; - - default: - slider.Current.Parse(textBox.Current.Value, CultureInfo.CurrentCulture); - break; - } - } - catch - { - // ignore parsing failures. - // sane state will eventually be restored by a commit (either explicit, or implicit via focus loss). - } - - updatingFromTextBox = false; - } - - private void updateTextBoxFromSlider(ValueChangedEvent _) - { - if (updatingFromTextBox) return; - - decimal decimalValue = decimal.CreateTruncating(slider.Current.Value); - textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); - } - } -} diff --git a/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs index cf569a73ca0e..802b91451a1e 100644 --- a/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -20,67 +19,46 @@ namespace osu.Game.Graphics.UserInterfaceV2 { public partial class SwitchButton : Checkbox { - private const float border_thickness = 4.5f; - private const float padding = 1.25f; + public const float WIDTH = 56; private readonly Box fill; - private readonly Container switchContainer; - private readonly Drawable switchCircle; - private readonly CircularBorderContainer circularContainer; + private readonly Container content; - private Color4 enabledColour; - private Color4 disabledColour; + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public bool ExpandOnCurrent { get; init; } = true; private Sample? sampleChecked; private Sample? sampleUnchecked; public SwitchButton() { - Size = new Vector2(45, 20); + Size = new Vector2(WIDTH, 16); - InternalChild = circularContainer = new CircularBorderContainer + InternalChild = content = new CircularContainer { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, BorderColour = Color4.White, - BorderThickness = border_thickness, + BorderThickness = 3.2f, Masking = true, + CornerExponent = 2.5f, Children = new Drawable[] { fill = new Box { RelativeSizeAxes = Axes.Both, AlwaysPresent = true, - Alpha = 0 }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(border_thickness + padding), - Child = switchContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Child = switchCircle = new CircularContainer - { - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Masking = true, - Child = new Box { RelativeSizeAxes = Axes.Both } - } - } - } } }; } [BackgroundDependencyLoader(true)] - private void load(OverlayColourProvider? colourProvider, OsuColour colours, AudioManager audio) + private void load(AudioManager audio) { - enabledColour = colourProvider?.Highlight1 ?? colours.BlueDark; - disabledColour = colourProvider?.Background3 ?? colours.Gray3; - - switchContainer.Colour = enabledColour; - fill.Colour = disabledColour; - sampleChecked = audio.Samples.Get(@"UI/check-on"); sampleUnchecked = audio.Samples.Get(@"UI/check-off"); } @@ -89,49 +67,65 @@ protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(updateState, true); - FinishTransforms(true); - } - - private void updateState(ValueChangedEvent state) - { - switchCircle.MoveToX(state.NewValue ? switchContainer.DrawWidth - switchCircle.DrawWidth : 0, 200, Easing.OutQuint); - fill.FadeTo(state.NewValue ? 1 : 0, 250, Easing.OutQuint); + Current.BindDisabledChanged(_ => updateState()); + Current.BindValueChanged(_ => updateState(), true); - updateBorder(); + FinishTransforms(true); } protected override bool OnHover(HoverEvent e) { - updateBorder(); + updateState(); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - updateBorder(); + updateState(); base.OnHoverLost(e); } protected override void OnUserChange(bool value) { base.OnUserChange(value); + PlaySample(value); + } + public void PlaySample(bool value) + { if (value) sampleChecked?.Play(); else sampleUnchecked?.Play(); } - private void updateBorder() + private void updateState() { - circularContainer.TransformBorderTo((Current.Value ? enabledColour : disabledColour).Lighten(IsHovered ? 0.3f : 0)); - } + Color4 fillColour = colourProvider.Background5.Opacity(0); + Color4 borderColour = colourProvider.Light4; - private partial class CircularBorderContainer : CircularContainer - { - public void TransformBorderTo(ColourInfo colour) - => this.TransformTo(nameof(BorderColour), colour, 250, Easing.OutQuint); + if (IsHovered) + borderColour = colourProvider.Highlight1; + else if (Current.Value) + borderColour = colourProvider.Highlight1.Darken(0.1f); + + if (Current.Value) + fillColour = borderColour; + + if (Current.Disabled) + { + fillColour = fillColour.Darken(0.4f); + borderColour = borderColour.Darken(0.4f); + } + + fill.FadeColour(fillColour, 250, Easing.OutQuint); + + content.TransformTo(nameof(BorderColour), (ColourInfo)borderColour, 250, Easing.OutQuint); + + if (ExpandOnCurrent && Current.Value) + content.ResizeWidthTo(1f, 200, Easing.OutElasticQuarter); + else + content.ResizeWidthTo(0.75f, 120, Easing.OutExpo); } } } diff --git a/osu.Game/IO/Archives/ZipArchiveReader.cs b/osu.Game/IO/Archives/ZipArchiveReader.cs index 8b9ecc74624c..44cc6b69da56 100644 --- a/osu.Game/IO/Archives/ZipArchiveReader.cs +++ b/osu.Game/IO/Archives/ZipArchiveReader.cs @@ -11,6 +11,7 @@ using Microsoft.Toolkit.HighPerformance; using osu.Framework.Extensions; using osu.Framework.IO.Stores; +using SharpCompress.Archives; using SharpCompress.Archives.Zip; using SharpCompress.Common; using SharpCompress.Readers; @@ -28,14 +29,18 @@ public sealed class ZipArchiveReader : ArchiveReader public static readonly ArchiveEncoding DEFAULT_ENCODING; private readonly Stream archiveStream; - private readonly ZipArchive archive; + private readonly IWritableArchive archive; static ZipArchiveReader() { // Required to support rare code pages. Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - DEFAULT_ENCODING = new ArchiveEncoding(Encoding.GetEncoding(932), Encoding.GetEncoding(932)); + DEFAULT_ENCODING = new ArchiveEncoding + { + Default = Encoding.GetEncoding(932), + Password = Encoding.GetEncoding(932), + }; } public ZipArchiveReader(Stream archiveStream, string name = null) @@ -43,7 +48,7 @@ public ZipArchiveReader(Stream archiveStream, string name = null) { this.archiveStream = archiveStream; - archive = ZipArchive.Open(archiveStream, new ReaderOptions + archive = ZipArchive.OpenArchive(archiveStream, new ReaderOptions { ArchiveEncoding = DEFAULT_ENCODING }); @@ -51,7 +56,7 @@ public ZipArchiveReader(Stream archiveStream, string name = null) public override Stream GetStream(string name) { - ZipArchiveEntry entry = archive.Entries.SingleOrDefault(e => e.Key == name); + IArchiveEntry entry = archive.Entries.SingleOrDefault(e => e.Key == name); if (entry == null) return null; diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 2aeb73d6c5f4..6da24e3571f4 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -105,6 +105,8 @@ public static IEnumerable GetGlobalActionsFor(GlobalActionCategory new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.R }, GlobalAction.RandomSkin), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.E }, GlobalAction.PreviousSkin), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.T }, GlobalAction.NextSkin), new KeyBinding(InputKey.F10, GlobalAction.ToggleGameplayMouseButtons), new KeyBinding(InputKey.F12, GlobalAction.TakeScreenshot), @@ -520,6 +522,12 @@ public enum GlobalAction [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleCurrentGroup))] ToggleCurrentGroup, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.PreviousSkin))] + PreviousSkin, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.NextSkin))] + NextSkin, } public enum GlobalActionCategory diff --git a/osu.Game/Localisation/AudioSettingsStrings.cs b/osu.Game/Localisation/AudioSettingsStrings.cs index 58caea7dd4eb..37ebdd80e062 100644 --- a/osu.Game/Localisation/AudioSettingsStrings.cs +++ b/osu.Game/Localisation/AudioSettingsStrings.cs @@ -99,6 +99,21 @@ public static class AudioSettingsStrings /// public static LocalisableString AdjustBeatmapOffsetAutomaticallyTooltip => new TranslatableString(getKey(@"adjust_beatmap_offset_automatically_tooltip"), @"If enabled, the offset suggested from last play on a beatmap is automatically applied."); + /// + /// "Use experimental audio mode" + /// + public static LocalisableString WasapiLabel => new TranslatableString(getKey(@"wasapi_label"), @"Use experimental audio mode"); + + /// + /// "This will attempt to initialise the audio engine in a lower latency mode." + /// + public static LocalisableString WasapiTooltip => new TranslatableString(getKey(@"wasapi_tooltip"), @"This will attempt to initialise the audio engine in a lower latency mode."); + + /// + /// "Due to reduced latency, your audio offset will need to be adjusted when enabling this setting. Generally expect to subtract 20 - 60 ms from your known value." + /// + public static LocalisableString WasapiNotice => new TranslatableString(getKey(@"wasapi_notice"), @"Due to reduced latency, your audio offset will need to be adjusted when enabling this setting. Generally expect to subtract 20 - 60 ms from your known value."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/BeatmapLeaderboardWedgeStrings.cs b/osu.Game/Localisation/BeatmapLeaderboardWedgeStrings.cs index 68c1920a1b16..59d97f505d3d 100644 --- a/osu.Game/Localisation/BeatmapLeaderboardWedgeStrings.cs +++ b/osu.Game/Localisation/BeatmapLeaderboardWedgeStrings.cs @@ -69,6 +69,16 @@ public class BeatmapLeaderboardWedgeStrings /// public static LocalisableString Date => new TranslatableString(getKey(@"date"), @"Date"); + /// + /// "Personal Best" + /// + public static LocalisableString PersonalBest => new TranslatableString(getKey(@"personal_best"), @"Personal Best"); + + /// + /// "Personal Best (#{0:N0} of {1:N0})" + /// + public static LocalisableString PersonalBestWithPosition(int position, int totalCount) => new TranslatableString(getKey(@"personal_best_with_position"), @"Personal Best (#{0:N0} of {1:N0})", position, totalCount); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/BeatmapOffsetControlStrings.cs b/osu.Game/Localisation/BeatmapOffsetControlStrings.cs index b905b7ae1c01..37412b6a7afa 100644 --- a/osu.Game/Localisation/BeatmapOffsetControlStrings.cs +++ b/osu.Game/Localisation/BeatmapOffsetControlStrings.cs @@ -39,6 +39,11 @@ public static class BeatmapOffsetControlStrings /// public static LocalisableString HitObjectsAppearEarlier => new TranslatableString(getKey(@"hit_objects_appear_earlier"), @"(hit objects appear earlier)"); + /// + /// "Beatmap offset was adjusted to {0} ms." + /// + public static LocalisableString BeatmapOffsetWasAdjustedTo(string offset) => new TranslatableString(getKey(@"beatmap_offset_was_adjusted_to"), @"Beatmap offset was adjusted to {0} ms.", offset); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/BindingSettingsStrings.cs b/osu.Game/Localisation/BindingSettingsStrings.cs index ad4a650a1f3e..645192e6ef03 100644 --- a/osu.Game/Localisation/BindingSettingsStrings.cs +++ b/osu.Game/Localisation/BindingSettingsStrings.cs @@ -10,9 +10,9 @@ public static class BindingSettingsStrings private const string prefix = @"osu.Game.Resources.Localisation.BindingSettings"; /// - /// "Shortcut and gameplay bindings" + /// "Shortcuts and gameplay bindings" /// - public static LocalisableString ShortcutAndGameplayBindings => new TranslatableString(getKey(@"shortcut_and_gameplay_bindings"), @"Shortcut and gameplay bindings"); + public static LocalisableString ShortcutAndGameplayBindings => new TranslatableString(getKey(@"shortcut_and_gameplay_bindings"), @"Shortcuts and gameplay bindings"); /// /// "Configure" diff --git a/osu.Game/Localisation/ButtonSystemStrings.cs b/osu.Game/Localisation/ButtonSystemStrings.cs index a9bc3068da83..ecb067e0ff86 100644 --- a/osu.Game/Localisation/ButtonSystemStrings.cs +++ b/osu.Game/Localisation/ButtonSystemStrings.cs @@ -59,6 +59,21 @@ public static class ButtonSystemStrings /// public static LocalisableString DailyChallenge => new TranslatableString(getKey(@"daily_challenge"), @"daily challenge"); + /// + /// "lounge" + /// + public static LocalisableString Lounge => new TranslatableString(getKey(@"lounge"), @"lounge"); + + /// + /// "quick play" + /// + public static LocalisableString QuickPlay => new TranslatableString(getKey(@"quick_play"), @"quick play"); + + /// + /// "ranked play" + /// + public static LocalisableString RankedPlay => new TranslatableString(getKey(@"ranked_play"), @"ranked play"); + /// /// "A few important words from your dev team!" /// @@ -78,6 +93,11 @@ public static class ButtonSystemStrings Please bear with us as we continue to improve the game for you!"); + /// + /// "Understood" + /// + public static LocalisableString MobileDisclaimerOkButton => new TranslatableString(getKey(@"mobile_disclaimer_ok_button"), @"Understood"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index c8630f933270..d72257f43804 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -199,6 +199,11 @@ public static class CommonStrings /// public static LocalisableString Mapper => new TranslatableString(getKey(@"mapper"), @"Mapper"); + /// + /// "Delete..." + /// + public static LocalisableString DeleteWithConfirmation => new TranslatableString(getKey(@"delete_with_confrmation"), @"Delete..."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/DialogStrings.cs b/osu.Game/Localisation/DialogStrings.cs index a7634575b878..05a7055cd638 100644 --- a/osu.Game/Localisation/DialogStrings.cs +++ b/osu.Game/Localisation/DialogStrings.cs @@ -29,6 +29,122 @@ public static class DialogStrings /// public static LocalisableString Cancel => new TranslatableString(getKey(@"cancel"), @"No! Abort mission"); + /// + /// "Failed to automatically locate an osu!stable installation." + /// + public static LocalisableString StableDirectoryLocationHeaderText => new TranslatableString(getKey(@"stable_directory_location_header_text"), @"Failed to automatically locate an osu!stable installation."); + + /// + /// "An existing install could not be located. If you know where it is, you can help locate it." + /// + public static LocalisableString StableDirectoryLocationBodyText => new TranslatableString(getKey(@"stable_directory_location_body_text"), @"An existing install could not be located. If you know where it is, you can help locate it."); + + /// + /// "Sure! I know where it is located!" + /// + public static LocalisableString StableDirectoryLocationOkButton => new TranslatableString(getKey(@"stable_directory_location_ok_button"), @"Sure! I know where it is located!"); + + /// + /// "Actually I don't have osu!stable installed." + /// + public static LocalisableString StableDirectoryLocationCancelButton => new TranslatableString(getKey(@"stable_directory_location_cancel_button"), @"Actually I don't have osu!stable installed."); + + /// + /// "All local scores on {0}" + /// + public static LocalisableString BeatmapClearScoresBodyText(string difficulty) => new TranslatableString(getKey(@"beatmap_clear_scores_body_text"), @"All local scores on {0}", difficulty); + + /// + /// "Are you sure you want to close the following playlist:" + /// + public static LocalisableString ClosePlaylistHeaderText => new TranslatableString(getKey(@"close_playlist_header_text"), @"Are you sure you want to close the following playlist:"); + + /// + /// "Are you sure you want to abort the match?" + /// + public static LocalisableString ConfirmAbortMatchHeaderText => new TranslatableString(getKey(@"confirm_abort_match_header_text"), @"Are you sure you want to abort the match?"); + + /// + /// "Are you sure you want to exit osu!?" + /// + public static LocalisableString ConfirmExitHeaderText => new TranslatableString(getKey(@"confirm_exit_header_text"), @"Are you sure you want to exit osu!?"); + + /// + /// "Last chance to turn back" + /// + public static LocalisableString ConfirmDialogBodyText => new TranslatableString(getKey(@"confirm_exit_body_text"), @"Last chance to turn back"); + + /// + /// "There are currently some background operations which will be aborted if you continue: + /// + /// {0}" + /// + public static LocalisableString ConfirmExitBodyTextOngoingOperations(string ongoingOperationsText) => new TranslatableString(getKey(@"confirm_exit_body_text_ongoing_operations"), @"There are currently some background operations which will be aborted if you continue: + +{0}", ongoingOperationsText); + + /// + /// "There are currently some background operations which will be aborted if you continue: + /// + /// {0} + /// + /// and {1} other operation(s)." + /// + public static LocalisableString ConfirmExitBodyTextOtherOngoingOperations(string ongoingOperationsText, int count) => new TranslatableString(getKey(@"confirm_exit_body_text_other_ongoing_operations"), @"There are currently some background operations which will be aborted if you continue: + +{0} + +and {1} other operation(s).", ongoingOperationsText, count); + + /// + /// "Let me out!" + /// + public static LocalisableString ConfirmExitOkButton => new TranslatableString(getKey(@"confirm_exit_ok_button"), @"Let me out!"); + + /// + /// "Just a little more..." + /// + public static LocalisableString ConfirmExitCancelButton => new TranslatableString(getKey(@"confirm_exit_cancel_button"), @"Just a little more..."); + + /// + /// "Are you sure you want to go back?" + /// + public static LocalisableString ConfirmDiscardChangesHeaderText => new TranslatableString(getKey(@"confirm_discard_changes_header_text"), @"Are you sure you want to go back?"); + + /// + /// "This will discard any unsaved changes" + /// + public static LocalisableString ConfirmDiscardChangesBodyText => new TranslatableString(getKey(@"confirm_discard_changes_body_text"), @"This will discard any unsaved changes"); + + /// + /// "No I didn't mean to" + /// + public static LocalisableString ConfirmDiscardChangesCancelButton => new TranslatableString(getKey(@"confirm_discard_changes_cancel_button"), @"No I didn't mean to"); + + /// + /// "Are you sure you want to open the following link in a web browser? + /// + /// {0}" + /// + public static LocalisableString ExternalLinkBodyText(string url) => new TranslatableString(getKey(@"external_link_body_text"), @"Are you sure you want to open the following link in a web browser? + +{0}", url); + + /// + /// "Open in browser" + /// + public static LocalisableString ExternalLinkOkButton => new TranslatableString(getKey(@"external_link_ok_button"), @"Open in browser"); + + /// + /// "Do you really want to delete your comment?" + /// + public static LocalisableString DeleteCommentBodyText => new TranslatableString(getKey(@"delete_comment_body_text"), @"Do you really want to delete your comment?"); + + /// + /// "Are you sure you want to leave this multiplayer match?" + /// + public static LocalisableString ConfirmExitMultiplayerMatchBodyText => new TranslatableString(getKey(@"confirm_exit_multiplayer_match_body_text"), @"Are you sure you want to leave this multiplayer match?"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/EditorDialogsStrings.cs b/osu.Game/Localisation/EditorDialogsStrings.cs index 3617dca81fdf..ea4e3d0d55a6 100644 --- a/osu.Game/Localisation/EditorDialogsStrings.cs +++ b/osu.Game/Localisation/EditorDialogsStrings.cs @@ -59,6 +59,26 @@ public static class EditorDialogsStrings /// public static LocalisableString DiscardUnsavedChangesDialogHeader => new TranslatableString(getKey(@"discard_unsaved_changes_dialog_header"), @"Discard all unsaved changes? This cannot be undone."); + /// + /// "The beatmap will be saved to continue with this operation." + /// + public static LocalisableString SaveRequiredDialogHeader => new TranslatableString(getKey(@"save_required_dialog_header"), @"The beatmap will be saved to continue with this operation."); + + /// + /// "Sounds good, let's go!" + /// + public static LocalisableString SoundsGood => new TranslatableString(getKey(@"sounds_good"), @"Sounds good, let's go!"); + + /// + /// "Difficulty "{0}" with {1} objects" + /// + public static LocalisableString DeleteDifficultyDetails(string difficultyName, int objectCount) => new TranslatableString(getKey(@"delete_difficulty_details"), @"Difficulty ""{0}"" with {1} objects", difficultyName, objectCount); + + /// + /// "All Bookmarks" + /// + public static LocalisableString AllBookmarks => new TranslatableString(getKey(@"all_bookmarks"), @"All Bookmarks"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/EditorSetupStrings.cs b/osu.Game/Localisation/EditorSetupStrings.cs index 8597b7d9a143..9469c8e63fb1 100644 --- a/osu.Game/Localisation/EditorSetupStrings.cs +++ b/osu.Game/Localisation/EditorSetupStrings.cs @@ -188,6 +188,11 @@ public static class EditorSetupStrings /// public static LocalisableString AudioTrack => new TranslatableString(getKey(@"audio_track"), @"Audio Track"); + /// + /// "Custom sample sets" + /// + public static LocalisableString CustomSampleSets => new TranslatableString(getKey(@"custom_sample_sets"), @"Custom sample sets"); + /// /// "Click to select a track" /// diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index c8b163c67859..d06aa4012c5f 100644 --- a/osu.Game/Localisation/EditorStrings.cs +++ b/osu.Game/Localisation/EditorStrings.cs @@ -19,6 +19,11 @@ public static class EditorStrings /// public static LocalisableString WaveformOpacity => new TranslatableString(getKey(@"waveform_opacity"), @"Waveform opacity"); + /// + /// "Show storyboard" + /// + public static LocalisableString ShowStoryboard => new TranslatableString(getKey(@"show_storyboard"), @"Show storyboard"); + /// /// "Show hit markers" /// @@ -54,6 +59,11 @@ public static class EditorStrings /// public static LocalisableString ExportForCompatibility => new TranslatableString(getKey(@"export_for_compatibility"), @"For compatibility (.osz)"); + /// + /// "Guest difficulty (.osu)" + /// + public static LocalisableString ExportGuestDifficulty => new TranslatableString(getKey(@"export_guest_difficulty"), @"Guest difficulty (.osu)"); + /// /// "Create new difficulty" /// diff --git a/osu.Game/Localisation/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index 2715f0b8cfdb..6c4ccfb5728b 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -120,9 +120,9 @@ public static class GameplaySettingsStrings public static LocalisableString ModsHeader => new TranslatableString(getKey(@"mods_header"), @"Mods"); /// - /// "Increase visibility of first object when visual impairment mods are enabled" + /// "Increase first object visibility on visual impairment mods" /// - public static LocalisableString IncreaseFirstObjectVisibility => new TranslatableString(getKey(@"increase_first_object_visibility"), @"Increase visibility of first object when visual impairment mods are enabled"); + public static LocalisableString IncreaseFirstObjectVisibility => new TranslatableString(getKey(@"increase_first_object_visibility"), @"Increase first object visibility on visual impairment mods"); /// /// "Hide during gameplay" diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index 20db5983fdeb..9b6276781ad2 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -29,6 +29,16 @@ public static class GeneralSettingsStrings /// public static LocalisableString Prefer24HourTimeDisplay => new TranslatableString(getKey(@"prefer_24_hour_time_display"), @"Prefer 24-hour time display"); + /// + /// "Installation" + /// + public static LocalisableString InstallationHeader => new TranslatableString(getKey(@"installation_header"), @"Installation"); + + /// + /// "Quick Actions" + /// + public static LocalisableString QuickActionsHeader => new TranslatableString(getKey(@"quick_actions_header"), @"Quick Actions"); + /// /// "Updates" /// @@ -79,6 +89,16 @@ public static class GeneralSettingsStrings /// public static LocalisableString LearnMoreAboutLazerTooltip => new TranslatableString(getKey(@"check_out_the_feature_comparison"), @"Check out the feature comparison and FAQ"); + /// + /// "Report an issue" + /// + public static LocalisableString ReportIssue => new TranslatableString(getKey(@"report_issue"), @"Report an issue"); + + /// + /// "Report a problem with the game to the developers." + /// + public static LocalisableString ReportIssueTooltip => new TranslatableString(getKey(@"report_issue_tooltip"), @"Report a problem with the game to the developers."); + /// /// "Check with your package manager / provider for other release streams." /// diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 8536249d354c..1ba051994531 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -229,6 +229,16 @@ public static class GlobalActionKeyBindingStrings /// public static LocalisableString RandomSkin => new TranslatableString(getKey(@"random_skin"), @"Random skin"); + /// + /// "Previous skin" + /// + public static LocalisableString PreviousSkin => new TranslatableString(getKey(@"previous_skin"), @"Previous skin"); + + /// + /// "Next skin" + /// + public static LocalisableString NextSkin => new TranslatableString(getKey(@"next_skin"), @"Next skin"); + /// /// "Pause / resume replay" /// diff --git a/osu.Game/Localisation/InputSettingsStrings.cs b/osu.Game/Localisation/InputSettingsStrings.cs index bc1a7e68ab01..661d583e8f34 100644 --- a/osu.Game/Localisation/InputSettingsStrings.cs +++ b/osu.Game/Localisation/InputSettingsStrings.cs @@ -14,6 +14,11 @@ public static class InputSettingsStrings /// public static LocalisableString InputSectionHeader => new TranslatableString(getKey(@"input_section_header"), @"Input"); + /// + /// "Device: {0}" + /// + public static LocalisableString Device(LocalisableString text) => new TranslatableString(getKey(@"device"), @"Device: {0}", text); + /// /// "Global" /// @@ -72,7 +77,8 @@ public static class InputSettingsStrings /// /// "The binding you've selected conflicts with another existing binding." /// - public static LocalisableString KeyBindingConflictDetected => new TranslatableString(getKey(@"key_binding_conflict_detected"), @"The binding you've selected conflicts with another existing binding."); + public static LocalisableString KeyBindingConflictDetected => + new TranslatableString(getKey(@"key_binding_conflict_detected"), @"The binding you've selected conflicts with another existing binding."); /// /// "Keep existing" diff --git a/osu.Game/Localisation/LoginPanelStrings.cs b/osu.Game/Localisation/LoginPanelStrings.cs index 925c2b91469f..243f0659065e 100644 --- a/osu.Game/Localisation/LoginPanelStrings.cs +++ b/osu.Game/Localisation/LoginPanelStrings.cs @@ -49,6 +49,16 @@ public static class LoginPanelStrings /// public static LocalisableString Register => new TranslatableString(getKey(@"register"), @"Register"); + /// + /// "An email has been sent to you with a verification code. Enter the code." + /// + public static LocalisableString CodeSent => new TranslatableString(getKey(@"code_sent"), @"An email has been sent to you with a verification code. Enter the code."); + + /// + /// "Enter code" + /// + public static LocalisableString EnterCode => new TranslatableString(getKey(@"enter_code"), @"Enter code"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/MenuTipStrings.cs b/osu.Game/Localisation/MenuTipStrings.cs index ebab9f4d0285..3f83fceb18db 100644 --- a/osu.Game/Localisation/MenuTipStrings.cs +++ b/osu.Game/Localisation/MenuTipStrings.cs @@ -100,9 +100,9 @@ public static class MenuTipStrings public static LocalisableString ModCustomisationSettings => new TranslatableString(getKey(@"mod_customisation_settings"), @"Many mods have customisation settings that drastically change how they function. Click the Customise button in mod select to view settings!"); /// - /// "Press {0} to switch to a random skin!" + /// "Press {0} to switch to a random skin! You can also use {1} and {2} to cycle through skins." /// - public static LocalisableString RandomSkinShortcut(LocalisableString keybind) => new TranslatableString(getKey(@"random_skin_shortcut"), @"Press {0} to switch to a random skin!", keybind); + public static LocalisableString SkinChangeShortcuts(LocalisableString[] keybind) => new TranslatableString(getKey(@"random_skin_shortcut"), @"Press {0} to switch to a random skin! You can also use {1} and {2} to cycle through skins.", keybind[0], keybind[1], keybind[2]); /// /// "While watching a replay, press {0} to toggle replay settings!" diff --git a/osu.Game/Localisation/ModSelectOverlayStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs index 10037d30c301..5572000c9912 100644 --- a/osu.Game/Localisation/ModSelectOverlayStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -19,6 +19,11 @@ public static class ModSelectOverlayStrings /// public static LocalisableString Mods(int count) => new TranslatableString(getKey(@"mods"), @"{0} mods", count); + /// + /// "all mods" + /// + public static LocalisableString AllMods => new TranslatableString(getKey(@"all_mods"), @"all mods"); + /// /// "Mods provide different ways to enjoy gameplay. Some have an effect on the score you can achieve during ranked play. Others are just for fun." /// diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 66250d162943..a8dac2d25c94 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -135,6 +135,133 @@ public static class NotificationsStrings /// public static LocalisableString Mention => new TranslatableString(getKey(@"mention"), @"Mention"); + /// + /// "Online: {0}" + /// + public static LocalisableString FriendOnline(string info) => new TranslatableString(getKey(@"friend_online"), @"Online: {0}", info); + + /// + /// "Offline: {0}" + /// + public static LocalisableString FriendOffline(string info) => new TranslatableString(getKey(@"friend_offline"), @"Offline: {0}", info); + + /// + /// "Connection to online services was interrupted. osu! will be operating with limited functionality." + /// + public static LocalisableString APIConnectionInterrupted => new TranslatableString(getKey(@"api_connection_interrupted"), @"Connection to online services was interrupted. osu! will be operating with limited functionality."); + + /// + /// "You have been logged out on this device due to a login to your account on another device." + /// + public static LocalisableString AnotherDeviceDisconnect => new TranslatableString(getKey(@"another_device_disconnect"), @"You have been logged out on this device due to a login to your account on another device."); + + /// + /// "You have been logged out due to a change to your account. Please log in again." + /// + public static LocalisableString AccountChangeDisconnect => new TranslatableString(getKey(@"account_change_disconnect"), @"You have been logged out due to a change to your account. Please log in again."); + + /// + /// "Downloading {0}" + /// + public static LocalisableString Downloading(string info) => new TranslatableString(getKey(@"downloading"), @"Downloading {0}", info); + + /// + /// "Collections import is initialising..." + /// + public static LocalisableString CollectionsImportInitialising => new TranslatableString(getKey(@"collections_import_initialising"), @"Collections import is initialising..."); + + /// + /// "Reading collections..." + /// + public static LocalisableString ReadingCollections => new TranslatableString(getKey(@"reading_collections"), @"Reading collections..."); + + /// + /// "Imported {0} collections" + /// + public static LocalisableString CollectionsImportProgress(int count) => new TranslatableString(getKey(@"collections_import_progress"), @"Imported {0} collections", count); + + /// + /// "Imported {0} of {1} collections" + /// + public static LocalisableString CollectionsImportProgressTotal(int count, int totalCount) => new TranslatableString(getKey(@"collections_import_progress_total"), @"Imported {0} of {1} collections", count, totalCount); + + /// + /// "This error has been automatically reported to the dev team." + /// + public static LocalisableString ErrorAutomaticallyReported => new TranslatableString(getKey(@"error_automatically_reported"), @"This error has been automatically reported to the dev team."); + + /// + /// "A newer release of osu! has been found ({0} → {1})." + /// + public static LocalisableString UpdateAvailable(string oldVersion, string newVersion) => new TranslatableString(getKey(@"update_available"), @"A newer release of osu! has been found ({0} → {1}).", oldVersion, newVersion); + + /// + /// "Click here to download the new version, which can be installed over the top of your existing installation." + /// + public static LocalisableString UpdateAvailableManualInstall => new TranslatableString(getKey(@"update_available_manual_install"), @"Click here to download the new version, which can be installed over the top of your existing installation."); + + /// + /// "Check with your package manager / provider to bring osu! up-to-date!" + /// + public static LocalisableString UpdateAvailablePackageManaged => new TranslatableString(getKey(@"update_available_package_managed"), @"Check with your package manager / provider to bring osu! up-to-date!"); + + /// + /// "An action was interrupted due to a dialog being displayed." + /// + public static LocalisableString ActionInterruptedByDialog => new TranslatableString(getKey(@"action_interrupted_by_dialog"), @"An action was interrupted due to a dialog being displayed."); + + /// + /// "Exporting {0}..." + /// + public static LocalisableString FileExportOngoing(string filename) => new TranslatableString(getKey(@"file_export_ongoing"), @"Exporting {0}...", filename); + + /// + /// "Exported {0}! Click to view." + /// + public static LocalisableString FileExportFinished(string filename) => new TranslatableString(getKey(@"file_export_finished"), @"Exported {0}! Click to view.", filename); + + /// + /// "Exporting logs..." + /// + public static LocalisableString LogsExportOngoing => new TranslatableString(getKey(@"logs_export_ongoing"), @"Exporting logs..."); + + /// + /// "Exported logs! Click to view." + /// + public static LocalisableString LogsExportFinished => new TranslatableString(getKey(@"logs_export_finished"), @"Exported logs! Click to view."); + + /// + /// "Running osu! as {0} does not improve performance, may break integrations and poses a security risk. Please run the game as a normal user." + /// + public static LocalisableString ElevatedPrivileges(LocalisableString user) => new TranslatableString(getKey(@"elevated_privileges"), @"Running osu! as {0} does not improve performance, may break integrations and poses a security risk. Please run the game as a normal user.", user); + + /// + /// "Screenshot saved! Click to view. + /// {0}" + /// + public static LocalisableString ScreenshotSaved(string filename) => new TranslatableString(getKey(@"screenshot_saved"), @"Screenshot saved! Click to view. +{0}", filename); + + /// + /// "The multiplayer server will be right back..." + /// + public static LocalisableString MultiplayerServerShuttingDownImmediately => new TranslatableString(getKey(@"multiplayer_server_shutting_down_immediately"), @"The multiplayer server will be right back..."); + + /// + /// "The multiplayer server is restarting in {0}." + /// + public static LocalisableString MultiplayerServerShuttingDownRemaining(string remainingTime) => new TranslatableString(getKey(@"multiplayer_server_shutting_down_remaining"), @"The multiplayer server is restarting in {0}.", remainingTime); + + /// + /// "Created new collection "{0}" with {1} beatmaps." + /// + public static LocalisableString CollectionCreated(string name, int beatmapsCount) => new TranslatableString(getKey(@"collection_created"), @"Created new collection ""{0}"" with {1} beatmaps.", name, beatmapsCount); + + /// + /// "Added {0} beatmaps to collection "{1}"." + /// + public static LocalisableString CollectionBeatmapsAdded(string name, int beatmapsCount) => new TranslatableString(getKey(@"collection_beatmaps_added"), @"Added {0} beatmaps to collection ""{1}"".", beatmapsCount, name); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/OnlinePlayStrings.cs b/osu.Game/Localisation/OnlinePlayStrings.cs index 1918519d36cb..32ef5d66fcb0 100644 --- a/osu.Game/Localisation/OnlinePlayStrings.cs +++ b/osu.Game/Localisation/OnlinePlayStrings.cs @@ -24,6 +24,31 @@ public static class OnlinePlayStrings /// public static LocalisableString InviteFailedUserOptOut => new TranslatableString(getKey(@"cant_invite_this_user_as1"), @"Can't invite this user as they have opted out of non-friend communications."); + /// + /// "Add to playlist" + /// + public static LocalisableString FooterButtonPlaylistAdd => new TranslatableString(getKey(@"footer_button_playlist_add"), @"Add to playlist"); + + /// + /// "Freemods" + /// + public static LocalisableString FooterButtonFreemods => new TranslatableString(getKey(@"footer_button_freemods"), @"Freemods"); + + /// + /// "Freestyle" + /// + public static LocalisableString FooterButtonFreestyle => new TranslatableString(getKey(@"footer_button_freestyle"), @"Freestyle"); + + /// + /// "{0} item(s)" + /// + public static LocalisableString PlaylistTrayItems(int count) => new TranslatableString(getKey(@"playlist_tray_items"), @"{0} item(s)", count); + + /// + /// "Manage items on previous screen" + /// + public static LocalisableString PlaylistTrayDescription => new TranslatableString(getKey(@"playlist_tray_description"), @"Manage items on previous screen"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/RankingStatisticsStrings.cs b/osu.Game/Localisation/RankingStatisticsStrings.cs new file mode 100644 index 000000000000..821c3db6f15f --- /dev/null +++ b/osu.Game/Localisation/RankingStatisticsStrings.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class RankingStatisticsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.RankingStatisticsStrings"; + + /// + /// "Average Hit Error" + /// + public static LocalisableString AverageHitErrorTitle => new TranslatableString(getKey(@"average_hit_error_title"), @"Average Hit Error"); + + /// + /// "Unstable Rate" + /// + public static LocalisableString UnstableRateTitle => new TranslatableString(getKey(@"unstable_rate_title"), @"Unstable Rate"); + + /// + /// "{0:N2} ms early" + /// + public static LocalisableString Early(double offset) => new TranslatableString(getKey(@"early"), @"{0:N2} ms early", offset); + + /// + /// "{0:N2} ms late" + /// + public static LocalisableString Late(double offset) => new TranslatableString(getKey(@"late"), @"{0:N2} ms late", offset); + + /// + /// "(not available)" + /// + public static LocalisableString NotAvailable => new TranslatableString(getKey(@"not_available"), @"(not available)"); + + /// + /// "Classic scoring mode is always used for this statistic." + /// + public static LocalisableString ClassicScoringAlwaysUsed => new TranslatableString(getKey(@"classic_scoring_always_used"), @"Classic scoring mode is always used for this statistic."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/SettingsStrings.cs b/osu.Game/Localisation/SettingsStrings.cs index aa2e2740ebe8..7208be8c9195 100644 --- a/osu.Game/Localisation/SettingsStrings.cs +++ b/osu.Game/Localisation/SettingsStrings.cs @@ -19,6 +19,11 @@ public static class SettingsStrings /// public static LocalisableString HeaderDescription => new TranslatableString(getKey(@"header_description"), @"change the way osu! behaves"); + /// + /// "Copy version" + /// + public static LocalisableString CopyVersion => new TranslatableString(getKey(@"copy_version"), @"Copy version"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs index 16dca7fd87c8..c453a09f07fc 100644 --- a/osu.Game/Localisation/SkinSettingsStrings.cs +++ b/osu.Game/Localisation/SkinSettingsStrings.cs @@ -19,6 +19,11 @@ public static class SkinSettingsStrings /// public static LocalisableString CurrentSkin => new TranslatableString(getKey(@"current_skin"), @"Current skin"); + /// + /// "Skin name" + /// + public static LocalisableString SkinName => new TranslatableString(getKey(@"skin_name"), @"Skin name"); + /// /// "Skin layout editor" /// diff --git a/osu.Game/Localisation/SongSelectStrings.cs b/osu.Game/Localisation/SongSelectStrings.cs index c81cf97f09fe..169f4782a7b7 100644 --- a/osu.Game/Localisation/SongSelectStrings.cs +++ b/osu.Game/Localisation/SongSelectStrings.cs @@ -109,6 +109,11 @@ public static class SongSelectStrings /// public static LocalisableString UseTheseMods => new TranslatableString(getKey(@"use_these_mods"), @"Use these mods"); + /// + /// "Watch replay" + /// + public static LocalisableString WatchReplay => new TranslatableString(getKey(@"watch_replay"), @"Watch replay"); + /// /// "For all difficulties" /// @@ -139,11 +144,6 @@ public static class SongSelectStrings /// public static LocalisableString ClearAllLocalScores => new TranslatableString(getKey(@"clear_all_local_scores"), @"Clear all local scores"); - /// - /// "Delete beatmap" - /// - public static LocalisableString DeleteBeatmap => new TranslatableString(getKey(@"delete_beatmap"), @"Delete beatmap"); - /// /// "Restore all hidden" /// @@ -259,6 +259,16 @@ public static class SongSelectStrings /// public static LocalisableString NoMatchingBeatmapsDescription => new TranslatableString(getKey(@"no_matching_beatmaps_description"), @"No beatmaps match your filter criteria!"); + /// + /// "Temporarily showing all beatmaps in" + /// + public static LocalisableString TemporarilyShowingAllBeatmapsIn => new TranslatableString(getKey(@"temporarily_showing_all_beatmaps_in"), @"Temporarily showing all beatmaps in"); + + /// + /// "mostly {0}" + /// + public static LocalisableString MostlyBPM(int mostCommonBPM) => new TranslatableString(getKey(@"mostly_bpm"), @"mostly {0}", mostCommonBPM); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ToolbarStrings.cs b/osu.Game/Localisation/ToolbarStrings.cs index 5822f76e0272..acc5bc60305e 100644 --- a/osu.Game/Localisation/ToolbarStrings.cs +++ b/osu.Game/Localisation/ToolbarStrings.cs @@ -39,6 +39,11 @@ public static class ToolbarStrings /// public static LocalisableString PlaySomeRuleset(string arg0) => new TranslatableString(getKey(@"play_some_ruleset"), @"play some {0}", arg0); + /// + /// "running" + /// + public static LocalisableString TimeRunning => new TranslatableString(getKey(@"time_running"), @"running"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/UserInterfaceStrings.cs b/osu.Game/Localisation/UserInterfaceStrings.cs index 7fbccf1919e6..1b48166c6409 100644 --- a/osu.Game/Localisation/UserInterfaceStrings.cs +++ b/osu.Game/Localisation/UserInterfaceStrings.cs @@ -59,6 +59,11 @@ public static class UserInterfaceStrings /// public static LocalisableString IntroSequence => new TranslatableString(getKey(@"intro_sequence"), @"Intro sequence"); + /// + /// "Random" + /// + public static LocalisableString IntroRandom => new TranslatableString(getKey(@"intro_random"), @"Random"); + /// /// "Background source" /// @@ -160,15 +165,35 @@ public static class UserInterfaceStrings public static LocalisableString NeverRepeat => new TranslatableString(getKey(@"never_repeat_random"), @"Never repeat"); /// - /// "True Random" + /// "True random" /// - public static LocalisableString TrueRandom => new TranslatableString(getKey(@"true_random"), @"True Random"); + public static LocalisableString TrueRandom => new TranslatableString(getKey(@"true_random"), @"True random"); /// /// "Selected Mods" /// public static LocalisableString SelectedMods => new TranslatableString(getKey(@"selected_mods"), @"Selected Mods"); + /// + /// "hold for menu" + /// + public static LocalisableString HoldForMenu => new TranslatableString(getKey(@"hold_for_menu"), @"hold for menu"); + + /// + /// "press for menu" + /// + public static LocalisableString PressForMenu => new TranslatableString(getKey(@"press_for_menu"), @"press for menu"); + + /// + /// "Device" + /// + public static LocalisableString Device => new TranslatableString(getKey(@"device"), @"Device"); + + /// + /// "Show hidden" + /// + public static LocalisableString ShowHidden => new TranslatableString(getKey(@"show_hidden"), @"Show hidden"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 6694003b31d9..0b5aa9050d9d 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -317,7 +317,7 @@ private void attemptConnect() userReq.Failure += ex => { - if (ex is APIException) + if (ex is APIException apiException && apiException.StatusCode < HttpStatusCode.InternalServerError) { LastLoginError = ex; log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!"); @@ -428,10 +428,10 @@ public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, // attempt to parse a non-form error message var response = JObject.Parse(req.GetResponseString().AsNonNull()); - string redirect = (string)response.SelectToken(@"url", true); + string redirect = (string)response.SelectToken(@"url", false); string message = (string)response.SelectToken(@"error", false); - if (!string.IsNullOrEmpty(redirect)) + if (!string.IsNullOrEmpty(redirect) || !string.IsNullOrEmpty(message)) { return new RegistrationRequest.RegistrationRequestErrors { @@ -603,7 +603,7 @@ protected override void Dispose(bool isDisposing) cancellationToken.Cancel(); } - private class WebRequestFlushedException : Exception + internal class WebRequestFlushedException : Exception { public WebRequestFlushedException(APIState state) : base($@"Request failed from flush operation (state {state})") diff --git a/osu.Game/Online/API/APIException.cs b/osu.Game/Online/API/APIException.cs index 4327600e132e..79e4e823e780 100644 --- a/osu.Game/Online/API/APIException.cs +++ b/osu.Game/Online/API/APIException.cs @@ -2,14 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Net; namespace osu.Game.Online.API { public class APIException : InvalidOperationException { - public APIException(string message, Exception? innerException) + public HttpStatusCode? StatusCode { get; } + + public APIException(string message, Exception? innerException, HttpStatusCode? statusCode = null) : base(message, innerException) { + StatusCode = statusCode; } } } diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 9d9873cc6fd4..6b4c599d8722 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -221,7 +221,7 @@ public void Fail(Exception e) // attempt to decode a displayable error string. var error = JsonConvert.DeserializeObject(responseString); if (error != null) - e = new APIException(error.ErrorMessage, e); + e = new APIException(error.ErrorMessage, e, WebRequest?.ResponseStatusCode); } catch { diff --git a/osu.Game/Online/API/LocalUserState.cs b/osu.Game/Online/API/LocalUserState.cs index 1359d62ae746..94b298fdb4a4 100644 --- a/osu.Game/Online/API/LocalUserState.cs +++ b/osu.Game/Online/API/LocalUserState.cs @@ -62,6 +62,10 @@ public void SetLocalUser(APIMe me) localUser.Value = me; configSupporter.Value = me.IsSupporter; + // `last_visit` is assumed to be `null` if and only if the web-side "hide online presence toggle" is enabled + if (me.LastVisit == null) + configStatus.Value = UserStatus.Offline; + UpdateFriends(); UpdateBlocks(); UpdateFavouriteBeatmapSets(); diff --git a/osu.Game/Online/API/OAuth.cs b/osu.Game/Online/API/OAuth.cs index 48293108706e..772c1c2f45d8 100644 --- a/osu.Game/Online/API/OAuth.cs +++ b/osu.Game/Online/API/OAuth.cs @@ -67,7 +67,7 @@ internal void AuthenticateWithLogin(string username, string password) // attempt to decode a displayable error string. var error = JsonConvert.DeserializeObject(accessTokenRequest.GetResponseString() ?? string.Empty); if (error != null) - throwableException = new APIException(error.UserDisplayableError, ex); + throwableException = new APIException(error.UserDisplayableError, ex, accessTokenRequest.ResponseStatusCode); } catch { diff --git a/osu.Game/Online/API/RegistrationRequest.cs b/osu.Game/Online/API/RegistrationRequest.cs index 78633f70b7a5..4498bc541824 100644 --- a/osu.Game/Online/API/RegistrationRequest.cs +++ b/osu.Game/Online/API/RegistrationRequest.cs @@ -14,9 +14,11 @@ public class RegistrationRequest : OsuWebRequest protected override void PrePerform() { - AddParameter("user[username]", Username); - AddParameter("user[user_email]", Email); - AddParameter("user[password]", Password); + AddParameter(@"user[username]", Username); + AddParameter(@"user[user_email]", Email); + AddParameter(@"user[password]", Password); + + AddHeader(@"Accept", @"application/json"); base.PrePerform(); } diff --git a/osu.Game/Online/API/Requests/GetScoresRequest.cs b/osu.Game/Online/API/Requests/GetScoresRequest.cs index 87fb54a5a95a..eb0c82e7903a 100644 --- a/osu.Game/Online/API/Requests/GetScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetScoresRequest.cs @@ -4,7 +4,6 @@ using System; using osu.Game.Beatmaps; using osu.Game.Rulesets; -using osu.Game.Screens.Select.Leaderboards; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Mods; using System.Collections.Generic; @@ -12,6 +11,7 @@ using System.Linq; using osu.Framework.IO.Network; using osu.Game.Extensions; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Online.API.Requests { diff --git a/osu.Game/Online/API/Requests/LookupUsersRequest.cs b/osu.Game/Online/API/Requests/LookupUsersRequest.cs index 6e98ce064e9a..99c1c551e4f1 100644 --- a/osu.Game/Online/API/Requests/LookupUsersRequest.cs +++ b/osu.Game/Online/API/Requests/LookupUsersRequest.cs @@ -10,21 +10,26 @@ namespace osu.Game.Online.API.Requests /// Looks up users with the given . /// In comparison to , the response here does not contain , /// but in exchange is subject to less stringent rate limiting, making it suitable for mass user listings. + /// + /// Providing a ruleset ID will give `global_rank`s in the response. /// public class LookupUsersRequest : APIRequest { public readonly int[] UserIds; + public readonly int? RulesetId; + private const int max_ids_per_request = 50; - public LookupUsersRequest(int[] userIds) + public LookupUsersRequest(int[] userIds, int? rulesetId = null) { if (userIds.Length > max_ids_per_request) throw new ArgumentException($"{nameof(LookupUsersRequest)} calls only support up to {max_ids_per_request} IDs at once"); UserIds = userIds; + RulesetId = rulesetId; } - protected override string Target => @"users/lookup/?ids[]=" + string.Join(@"&ids[]=", UserIds); + protected override string Target => @"users/lookup/?ids[]=" + string.Join(@"&ids[]=", UserIds) + (RulesetId != null ? "&ruleset_id=" + RulesetId : ""); } } diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index 20494a1cbfab..9c7992736329 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Extensions; @@ -115,6 +117,37 @@ private double hitLengthInSeconds [JsonProperty(@"owners")] public BeatmapOwner[] BeatmapOwners { get; set; } = Array.Empty(); + /// + /// Minimum count of votes required to display a tag on the beatmap's page. + /// Should match value specified web-side as https://github.com/ppy/osu-web/blob/cae2fdf03cfb8c30c8e332cfb142e03188ceffef/config/osu.php#L59. + /// + public const int MINIMUM_USER_TAG_VOTES_FOR_DISPLAY = 5; + + /// + /// Retrieves top user tags for the beatmap, ordered in a way matching osu!web. + /// Requires to be populated. + /// + /// + /// If , only tags above will be shown. + /// If , all tags regardless of vote count will be shown. + /// + public (APITag Tag, int VoteCount)[] GetTopUserTags(bool confirmedOnly = true) + { + if (TopTags == null || TopTags.Length == 0 || BeatmapSet?.RelatedTags == null) + return []; + + var tagsById = BeatmapSet.RelatedTags.ToDictionary(t => t.Id); + + return TopTags + .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) + .Where(t => t.relatedTag != null && (!confirmedOnly || t.topTag.VoteCount >= MINIMUM_USER_TAG_VOTES_FOR_DISPLAY)) + // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria + .OrderByDescending(t => t.topTag.VoteCount) + .ThenBy(t => t.relatedTag!.Name) + .Select(t => (t.relatedTag!, t.topTag.VoteCount)) + .ToArray(); + } + #region Implementation of IBeatmapInfo public IBeatmapMetadataInfo Metadata => (BeatmapSet as IBeatmapSetInfo)?.Metadata ?? new BeatmapMetadata(); @@ -146,6 +179,7 @@ public class APIRuleset : IRulesetInfo public string Name => $@"{nameof(APIRuleset)} (ID: {OnlineID})"; + [JsonIgnore] public string ShortName { get diff --git a/osu.Game/Online/API/Requests/Responses/APITeam.cs b/osu.Game/Online/API/Requests/Responses/APITeam.cs index b4fcc2d26e0b..1d4aa17cda69 100644 --- a/osu.Game/Online/API/Requests/Responses/APITeam.cs +++ b/osu.Game/Online/API/Requests/Responses/APITeam.cs @@ -18,6 +18,6 @@ public class APITeam public string ShortName { get; set; } = string.Empty; [JsonProperty(@"flag_url")] - public string FlagUrl = string.Empty; + public string? FlagUrl = string.Empty; } } diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index 6f122c58afd5..fa90e5cd509c 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -247,6 +247,20 @@ public UserStatistics Statistics } } + // Only provided via /users/ batch lookups. Usually implicitly comes inside `UserStatistics`. + [JsonProperty(@"global_rank")] + [CanBeNull] + public GlobalRank Rank { get; set; } + + public class GlobalRank + { + [JsonProperty(@"rank")] + public int? Rank; + + [JsonProperty(@"ruleset_id")] + public int RulesetId; + } + [JsonProperty(@"rank_history")] private APIRankHistory rankHistory { diff --git a/osu.Game/Online/API/Requests/VerifySessionRequest.cs b/osu.Game/Online/API/Requests/VerifySessionRequest.cs index d8f622348b07..88652bce7f2f 100644 --- a/osu.Game/Online/API/Requests/VerifySessionRequest.cs +++ b/osu.Game/Online/API/Requests/VerifySessionRequest.cs @@ -44,7 +44,7 @@ protected override WebRequest CreateWebRequest() private class VerificationFailureResponse { [JsonProperty("method")] - public SessionVerificationMethod RequiredSessionVerificationMethod { get; set; } + public SessionVerificationMethod? RequiredSessionVerificationMethod { get; set; } } } } diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index fde6c4db06ce..aec7928ba8b0 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -69,7 +70,9 @@ public partial class ChannelManager : CompositeComponent, IChannelPostTarget [Resolved] private UserLookupCache users { get; set; } + private readonly IBindable localUser = new Bindable(); private readonly IBindable apiState = new Bindable(); + private readonly IBindableList localUserBlocks = new BindableList(); private ScheduledDelegate scheduledAck; private IChatClient chatClient = null!; @@ -93,8 +96,30 @@ private void load() chatClient.PresenceReceived += () => Schedule(initializeChannels); chatClient.RequestPresence(); + localUser.BindTo(api.LocalUser); + localUser.BindValueChanged(userChanged); + apiState.BindTo(api.State); apiState.BindValueChanged(_ => SendAck(), true); + + localUserBlocks.BindTo(api.LocalUserState.Blocks); + localUserBlocks.BindCollectionChanged((_, args) => Schedule(() => onBlocksChanged(args))); + } + + private void userChanged(ValueChangedEvent userChange) + { + if (userChange.OldValue?.Equals(userChange.NewValue) == true) + return; + + CurrentChannel.Value = null; + + foreach (var joinedChannel in joinedChannels) + joinedChannel.Joined.Value = false; + + joinedChannels.Clear(); + // additionally clear the history of last joined channels so that the new user can't reopen the old user's channels + // (would likely fail web-side on perms anyway, but why even get that far) + closedChannels.Clear(); } /// @@ -311,8 +336,9 @@ public void PostCommand(string text, Channel target = null) private void addMessages(List messages) { var channels = JoinedChannels.ToList(); + var blockedUserIds = localUserBlocks.Select(b => b.TargetID).ToList(); - foreach (var group in messages.GroupBy(m => m.ChannelId)) + foreach (var group in messages.Where(m => !blockedUserIds.Contains(m.SenderId)).GroupBy(m => m.ChannelId)) channels.Find(c => c.Id == group.Key)?.AddNewMessages(group.ToArray()); lastSilenceMessageId ??= messages.LastOrDefault()?.Id; @@ -641,6 +667,18 @@ public void MarkChannelAsRead(Channel channel) api.Queue(req); } + private void onBlocksChanged(NotifyCollectionChangedEventArgs args) + { + if (args.Action != NotifyCollectionChangedAction.Add) + return; + + foreach (APIRelation newBlock in args.NewItems!) + { + foreach (var channel in joinedChannels) + channel.RemoveMessagesFromUser(newBlock.TargetID); + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 258cca2ad577..708e9edefa17 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -24,7 +24,7 @@ public partial class ExternalLinkOpener : Component private GameHost host { get; set; } = null!; [Resolved] - private Clipboard clipboard { get; set; } = null!; + private OsuGame? game { get; set; } [Resolved] private IDialogOverlay? dialogOverlay { get; set; } @@ -88,7 +88,7 @@ public void OpenUrlExternally(string url, LinkWarnMode warnMode = LinkWarnMode.D } if (dialogOverlay != null && shouldWarn) - dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url), () => clipboard.SetText(url))); + dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url), () => game?.CopyToClipboard(url))); else host.OpenUrlExternally(url); } @@ -98,7 +98,7 @@ public partial class ExternalLinkDialog : PopupDialog public ExternalLinkDialog(string url, Action openExternalLinkAction, Action copyExternalLinkAction) { HeaderText = DialogStrings.CautionHeaderText; - BodyText = $"Are you sure you want to open the following link in a web browser?\n\n{url}"; + BodyText = DialogStrings.ExternalLinkBodyText(url); Icon = FontAwesome.Solid.ExclamationTriangle; @@ -106,7 +106,7 @@ public ExternalLinkDialog(string url, Action openExternalLinkAction, Action copy { new PopupDialogOkButton { - Text = @"Open in browser", + Text = DialogStrings.ExternalLinkOkButton, Action = openExternalLinkAction }, new PopupDialogCancelButton diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index 5ba5b48e59ae..77d0421354f2 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; @@ -44,14 +45,24 @@ public partial class FriendPresenceNotifier : Component private readonly HashSet onlineAlertQueue = new HashSet(); private readonly HashSet offlineAlertQueue = new HashSet(); - private double? lastOnlineAlertTime; - private double? lastOfflineAlertTime; + private double? nextOnlineAlertTime; + private double? nextOfflineAlertTime; + + private const double debounce_time_before_notification = 1000; protected override void LoadComplete() { base.LoadComplete(); config.BindWith(OsuSetting.NotifyOnFriendPresenceChange, notifyOnFriendPresenceChange); + notifyOnFriendPresenceChange.BindValueChanged(_ => + { + onlineAlertQueue.Clear(); + offlineAlertQueue.Clear(); + + nextOfflineAlertTime = null; + nextOnlineAlertTime = null; + }); friends.BindTo(api.LocalUserState.Friends); friends.BindCollectionChanged(onFriendsChanged, true); @@ -64,8 +75,11 @@ protected override void Update() { base.Update(); - alertOnlineUsers(); - alertOfflineUsers(); + if (notifyOnFriendPresenceChange.Value) + { + alertOnlineUsers(); + alertOfflineUsers(); + } } private void onFriendsChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -131,7 +145,7 @@ private void markUserOnline(APIUser user) if (!offlineAlertQueue.Remove(user)) { onlineAlertQueue.Add(user); - lastOnlineAlertTime ??= Time.Current; + nextOnlineAlertTime ??= Time.Current + debounce_time_before_notification; } } @@ -140,110 +154,122 @@ private void markUserOffline(APIUser user) if (!onlineAlertQueue.Remove(user)) { offlineAlertQueue.Add(user); - lastOfflineAlertTime ??= Time.Current; + nextOfflineAlertTime ??= Time.Current + debounce_time_before_notification; } } private void alertOnlineUsers() { - if (onlineAlertQueue.Count == 0) - return; - - if (lastOnlineAlertTime == null || Time.Current - lastOnlineAlertTime < 1000) + if (nextOnlineAlertTime == null || Time.Current < nextOnlineAlertTime) return; - if (!notifyOnFriendPresenceChange.Value) - { - lastOnlineAlertTime = null; - return; - } + // If a user quickly switches online-offline, we might reach here without actually having a notification + // to fire. Importantly, we should still reset the next alert time in such a scenario. - notifications.Post(new FriendOnlineNotification(onlineAlertQueue.ToArray())); + if (onlineAlertQueue.Count == 1) + notifications.Post(new SingleFriendOnlineNotification(onlineAlertQueue.Single())); + else if (onlineAlertQueue.Count > 1) + notifications.Post(new MultipleFriendsOnlineNotification(onlineAlertQueue.ToArray())); onlineAlertQueue.Clear(); - lastOnlineAlertTime = null; + nextOnlineAlertTime = null; } private void alertOfflineUsers() { - if (offlineAlertQueue.Count == 0) + if (nextOfflineAlertTime == null || Time.Current < nextOfflineAlertTime) return; - if (lastOfflineAlertTime == null || Time.Current - lastOfflineAlertTime < 1000) - return; + // If a user quickly switches offline-online, we might reach here without actually having a notification + // to fire. Importantly, we should still reset the next alert time in such a scenario. - if (!notifyOnFriendPresenceChange.Value) - { - lastOfflineAlertTime = null; - return; - } - - notifications.Post(new FriendOfflineNotification(offlineAlertQueue.ToArray())); + if (offlineAlertQueue.Count == 1) + notifications.Post(new SingleFriendOfflineNotification(offlineAlertQueue.Single())); + else if (offlineAlertQueue.Count > 1) + notifications.Post(new MultipleFriendsOfflineNotification(offlineAlertQueue.ToArray())); offlineAlertQueue.Clear(); - lastOfflineAlertTime = null; + nextOfflineAlertTime = null; } - public partial class FriendOnlineNotification : UserAvatarNotification + private partial class SingleFriendOnlineNotification : UserAvatarNotification { - private readonly ICollection users; - - public FriendOnlineNotification(ICollection users) - : base(users.Count == 1 ? users.Single() : null) + public SingleFriendOnlineNotification(APIUser user) + : base(user) { - this.users = users; - Transient = true; IsImportant = false; - Text = $"Online: {string.Join(@", ", users.Select(u => u.Username))}"; + Text = NotificationsStrings.FriendOnline(User.Username); } [BackgroundDependencyLoader] - private void load(OsuColour colours, ChannelManager channelManager, ChatOverlay chatOverlay) + private void load(ChannelManager channelManager, ChatOverlay chatOverlay) { - if (users.Count > 1) - { - Icon = FontAwesome.Solid.User; - IconColour = colours.GrayD; - } - else + Activated = () => { - Activated = () => - { - channelManager.OpenPrivateChannel(users.Single()); - chatOverlay.Show(); + channelManager.OpenPrivateChannel(User); + chatOverlay.Show(); + + return true; + }; + } + + public override string PopInSampleName => "UI/notification-friend-online"; + } + + private partial class MultipleFriendsOnlineNotification : SimpleNotification + { + public MultipleFriendsOnlineNotification(ICollection users) + { + Transient = true; + IsImportant = false; + Text = NotificationsStrings.FriendOnline(string.Join(@", ", users.Select(u => u.Username))); + } - return true; - }; - } + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Icon = FontAwesome.Solid.User; + IconColour = colours.Green; } public override string PopInSampleName => "UI/notification-friend-online"; } - public partial class FriendOfflineNotification : UserAvatarNotification + private partial class SingleFriendOfflineNotification : UserAvatarNotification { - private readonly ICollection users; + public SingleFriendOfflineNotification(APIUser user) + : base(user) + { + Transient = true; + IsImportant = false; + Text = NotificationsStrings.FriendOffline(User.Username); + } - public FriendOfflineNotification(ICollection users) - : base(users.Count == 1 ? users.Single() : null) + [BackgroundDependencyLoader] + private void load() { - this.users = users; + Icon = FontAwesome.Solid.UserSlash; + Avatar.Colour = Color4.White.Opacity(0.25f); + } + public override string PopInSampleName => "UI/notification-friend-offline"; + } + + private partial class MultipleFriendsOfflineNotification : SimpleNotification + { + public MultipleFriendsOfflineNotification(ICollection users) + { Transient = true; IsImportant = false; - Text = $"Offline: {string.Join(@", ", users.Select(u => u.Username))}"; + Text = NotificationsStrings.FriendOffline(string.Join(@", ", users.Select(u => u.Username))); } [BackgroundDependencyLoader] private void load(OsuColour colours) { Icon = FontAwesome.Solid.UserSlash; - - if (users.Count == 1) - Avatar.Colour = Color4.White.Opacity(0.25f); - else - IconColour = colours.Gray3; + IconColour = colours.Red; } public override string PopInSampleName => "UI/notification-friend-offline"; diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index e6391e8810ac..c12043c7271a 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; @@ -57,17 +57,9 @@ protected override Task BuildConnectionAsync(Cancellat { // Configuring proxies is not supported on iOS, see https://github.com/xamarin/xamarin-macios/issues/14632. if (RuntimeInfo.OS != RuntimeInfo.Platform.iOS) - { - // Use HttpClient.DefaultProxy once on net6 everywhere. - // The credential setter can also be removed at this point. - options.Proxy = WebRequest.DefaultWebProxy; - if (options.Proxy != null) - options.Proxy.Credentials = CredentialCache.DefaultCredentials; - } - - options.Headers.Add(@"Authorization", @$"Bearer {API.AccessToken}"); - // non-standard header name kept for backwards compatibility, can be removed after server side has migrated to `VERSION_HASH_HEADER` - options.Headers.Add(@"OsuVersionHash", versionHash); + options.Proxy = HttpClient.DefaultProxy; + + options.AccessTokenProvider = () => Task.FromResult(API.AccessToken); options.Headers.Add(VERSION_HASH_HEADER, versionHash); options.Headers.Add(CLIENT_SESSION_ID_HEADER, API.SessionIdentifier.ToString()); }); diff --git a/osu.Game/Online/Leaderboards/DrawableRank.cs b/osu.Game/Online/Leaderboards/DrawableRank.cs index f4f4165c7fb3..efcc14a5c184 100644 --- a/osu.Game/Online/Leaderboards/DrawableRank.cs +++ b/osu.Game/Online/Leaderboards/DrawableRank.cs @@ -115,5 +115,36 @@ public static ColourInfo GetRankLetterColour(ScoreRank rank) return Color4Extensions.FromHex(@"CC3333"); } } + + public static string GetLegacyRankTextureName(ScoreRank rank) + { + switch (rank) + { + case ScoreRank.XH: + return "ranking-XH"; + + case ScoreRank.SH: + return "ranking-SH"; + + case ScoreRank.X: + return "ranking-X"; + + case ScoreRank.S: + return "ranking-S"; + + case ScoreRank.A: + return "ranking-A"; + + case ScoreRank.B: + return "ranking-B"; + + case ScoreRank.C: + return "ranking-C"; + + default: + case ScoreRank.D: + return "ranking-D"; + } + } } } diff --git a/osu.Game/Online/Leaderboards/LeaderboardManager.cs b/osu.Game/Online/Leaderboards/LeaderboardManager.cs index de53acc3f617..d4a258a3079e 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardManager.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardManager.cs @@ -18,7 +18,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using Realms; namespace osu.Game.Online.Leaderboards diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index bc617cae8033..57682513d4bb 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -17,7 +17,6 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Framework.Platform; using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -35,6 +34,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Utils; using CommonStrings = osu.Game.Localisation.CommonStrings; +using SongSelect = osu.Game.Screens.Select.SongSelect; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Online.Leaderboards @@ -76,7 +76,7 @@ public partial class LeaderboardScore : OsuClickableContainer, IHasContextMenu, private SongSelect songSelect { get; set; } [Resolved(canBeNull: true)] - private Clipboard clipboard { get; set; } + private OsuGame game { get; set; } [Resolved] private IAPIProvider api { get; set; } @@ -459,7 +459,7 @@ public MenuItem[] ContextMenuItems items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = copyableMods)); if (Score.OnlineID > 0) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{Score.OnlineID}"))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard($@"{api.Endpoints.WebsiteUrl}/scores/{Score.OnlineID}"))); if (Score.Files.Count > 0) { diff --git a/osu.Game/Screens/Select/Leaderboards/LeaderboardSortMode.cs b/osu.Game/Online/Leaderboards/LeaderboardSortMode.cs similarity index 95% rename from osu.Game/Screens/Select/Leaderboards/LeaderboardSortMode.cs rename to osu.Game/Online/Leaderboards/LeaderboardSortMode.cs index d5fb2f3c5474..2f2418763d5c 100644 --- a/osu.Game/Screens/Select/Leaderboards/LeaderboardSortMode.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardSortMode.cs @@ -4,7 +4,7 @@ using osu.Framework.Localisation; using osu.Game.Localisation; -namespace osu.Game.Screens.Select.Leaderboards +namespace osu.Game.Online.Leaderboards { public enum LeaderboardSortMode { diff --git a/osu.Game/Online/Matchmaking/IMatchmakingClient.cs b/osu.Game/Online/Matchmaking/IMatchmakingClient.cs index 70e1ce0b5da8..f4263e21bee8 100644 --- a/osu.Game/Online/Matchmaking/IMatchmakingClient.cs +++ b/osu.Game/Online/Matchmaking/IMatchmakingClient.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Threading.Tasks; namespace osu.Game.Online.Matchmaking @@ -23,8 +24,20 @@ public interface IMatchmakingClient : IStatefulUserHubClient /// declined, /// or ignored - in which case it will automatically be declined after a short timeout period. /// + /// + /// Provided for compatibility with older clients - can be removed 20260825. + /// + [Obsolete] Task MatchmakingRoomInvited(); + /// + /// Signals that a match has been found and the local user is invited to it. + /// The invitation may be accepted, + /// declined, + /// or ignored - in which case it will automatically be declined after a short timeout period. + /// + Task MatchmakingRoomInvitedWithParams(MatchmakingRoomInvitationParams invitation); + /// /// Signals that the matchmaking room is ready to be opened. /// @@ -43,11 +56,15 @@ public interface IMatchmakingClient : IStatefulUserHubClient /// /// The user has raised a candidate playlist item to be played. /// + /// The notifying user. + /// The playlist item candidate raised, or -1 as a special value that indicates a random selection. Task MatchmakingItemSelected(int userId, long playlistItemId); /// /// The user has removed a candidate playlist item. /// + /// The notifying user. + /// The playlist item candidate removed, or -1 as a special value that indicates a random selection. Task MatchmakingItemDeselected(int userId, long playlistItemId); } } diff --git a/osu.Game/Online/Matchmaking/IMatchmakingServer.cs b/osu.Game/Online/Matchmaking/IMatchmakingServer.cs index 66fd8c36da2d..06689a40dae0 100644 --- a/osu.Game/Online/Matchmaking/IMatchmakingServer.cs +++ b/osu.Game/Online/Matchmaking/IMatchmakingServer.cs @@ -10,7 +10,8 @@ public interface IMatchmakingServer /// /// Retrieves all active matchmaking pools. /// - Task GetMatchmakingPools(); + /// + Task GetMatchmakingPoolsOfType(MatchmakingPoolType type); /// /// Joins the matchmaking lobby, allowing the local user to receive status updates. @@ -45,7 +46,7 @@ public interface IMatchmakingServer /// /// Raise a candidate playlist item to be played in the current round. /// - /// The playlist item. + /// The playlist item, or -1 to indicate a random selection. Task MatchmakingToggleSelection(long playlistItemId); /// diff --git a/osu.Game/Online/Matchmaking/MatchmakingPool.cs b/osu.Game/Online/Matchmaking/MatchmakingPool.cs index 3f256d525131..54b1d443b579 100644 --- a/osu.Game/Online/Matchmaking/MatchmakingPool.cs +++ b/osu.Game/Online/Matchmaking/MatchmakingPool.cs @@ -23,6 +23,9 @@ public class MatchmakingPool : IEquatable [Key(3)] public string Name { get; set; } = string.Empty; + [Key(4)] + public MatchmakingPoolType Type { get; set; } = MatchmakingPoolType.QuickPlay; + public bool Equals(MatchmakingPool? other) => other != null && Id == other.Id diff --git a/osu.Game/Online/Matchmaking/MatchmakingPoolType.cs b/osu.Game/Online/Matchmaking/MatchmakingPoolType.cs new file mode 100644 index 000000000000..f151d6186320 --- /dev/null +++ b/osu.Game/Online/Matchmaking/MatchmakingPoolType.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. + +namespace osu.Game.Online.Matchmaking +{ + public enum MatchmakingPoolType + { + QuickPlay, + RankedPlay + } +} diff --git a/osu.Game/Online/Matchmaking/MatchmakingRoomInvitationParams.cs b/osu.Game/Online/Matchmaking/MatchmakingRoomInvitationParams.cs new file mode 100644 index 000000000000..38b4d8573453 --- /dev/null +++ b/osu.Game/Online/Matchmaking/MatchmakingRoomInvitationParams.cs @@ -0,0 +1,16 @@ +// 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 MessagePack; + +namespace osu.Game.Online.Matchmaking +{ + [MessagePackObject] + [Serializable] + public class MatchmakingRoomInvitationParams + { + [Key(0)] + public MatchmakingPoolType Type { get; set; } + } +} diff --git a/osu.Game/Online/Metadata/IMetadataServer.cs b/osu.Game/Online/Metadata/IMetadataServer.cs index 79ed8b5634a0..dc1d486b4a5d 100644 --- a/osu.Game/Online/Metadata/IMetadataServer.cs +++ b/osu.Game/Online/Metadata/IMetadataServer.cs @@ -53,5 +53,10 @@ public interface IMetadataServer /// Signals to the server that the current user would like to stop receiving updates about the state of the multiplayer room with the given . /// Task EndWatchingMultiplayerRoom(long id); + + /// + /// Refresh this user's friend listing. + /// + Task RefreshFriends(); } } diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs index 0679191a52cb..e70fe6d3bb47 100644 --- a/osu.Game/Online/Metadata/MetadataClient.cs +++ b/osu.Game/Online/Metadata/MetadataClient.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Users; @@ -21,6 +22,16 @@ public abstract partial class MetadataClient : Component, IMetadataClient, IMeta [Resolved] private IAPIProvider api { get; set; } = null!; + private readonly IBindableList localFriends = new BindableList(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + localFriends.BindTo(api.LocalUserState.Friends); + localFriends.BindCollectionChanged((_, _) => RefreshFriends().FireAndForget()); + } + #region Beatmap metadata updates public abstract Task GetChangesSince(int queueId); @@ -152,6 +163,8 @@ public void Dispose() public abstract Task EndWatchingMultiplayerRoom(long id); + public abstract Task RefreshFriends(); + public event Action? MultiplayerRoomScoreSet; Task IMetadataClient.MultiplayerRoomScoreSet(MultiplayerRoomScoreSetEvent roomScoreSetEvent) diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index 75b01873889d..0853ccf9dfbd 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -235,15 +235,13 @@ public override Task UserPresenceUpdated(int userId, UserPresence? presence) { if (userId == api.LocalUser.Value.OnlineID) localUserPresence = presence.Value; - else - userPresences[userId] = presence.Value; + userPresences[userId] = presence.Value; } else { if (userId == api.LocalUser.Value.OnlineID) localUserPresence = default; - else - userPresences.Remove(userId); + userPresences.Remove(userId); } }); @@ -290,6 +288,15 @@ public override async Task EndWatchingMultiplayerRoom(long id) Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching multiplayer room with ID {id}", LoggingTarget.Network); } + public override async Task RefreshFriends() + { + if (connector?.IsConnected.Value != true) + throw new OperationCanceledException(); + + Debug.Assert(connection != null); + await connection.InvokeAsync(nameof(IMetadataServer.RefreshFriends)).ConfigureAwait(false); + } + public override async Task DisconnectRequested() { await base.DisconnectRequested().ConfigureAwait(false); diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index adb9b92614a0..c94faf173c82 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -149,5 +149,15 @@ public interface IMultiplayerClient : IStatefulUserHubClient /// /// The changed item. Task PlaylistItemChanged(MultiplayerPlaylistItem item); + + /// + /// Signals that a user has requested to skip the beatmap intro. + /// + Task UserVotedToSkipIntro(int userId, bool voted); + + /// + /// Signals that the vote to skip the beatmap intro has passed. + /// + Task VoteToSkipIntroPassed(); } } diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 490973faa2fe..836b9efb1037 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -16,7 +16,6 @@ public interface IMultiplayerRoomServer /// /// Request to leave the currently joined room. /// - /// If the user is not in a room. Task LeaveRoom(); /// @@ -112,6 +111,11 @@ public interface IMultiplayerRoomServer /// The item to remove. Task RemovePlaylistItem(long playlistItemId); + /// + /// Votes to skip the beatmap intro. + /// + Task VoteToSkipIntro(); + /// /// Invites a player to the current room. /// diff --git a/osu.Game/Online/Multiplayer/MatchRoomState.cs b/osu.Game/Online/Multiplayer/MatchRoomState.cs index 25de8c7fab1e..531395980674 100644 --- a/osu.Game/Online/Multiplayer/MatchRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchRoomState.cs @@ -4,6 +4,7 @@ using System; using MessagePack; using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; namespace osu.Game.Online.Multiplayer @@ -16,6 +17,7 @@ namespace osu.Game.Online.Multiplayer [MessagePackObject] [Union(0, typeof(TeamVersusRoomState))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. [Union(1, typeof(MatchmakingRoomState))] + [Union(2, typeof(RankedPlayRoomState))] public abstract class MatchRoomState { } diff --git a/osu.Game/Online/Multiplayer/MatchServerEvent.cs b/osu.Game/Online/Multiplayer/MatchServerEvent.cs index 529a2994388f..723452d3f63b 100644 --- a/osu.Game/Online/Multiplayer/MatchServerEvent.cs +++ b/osu.Game/Online/Multiplayer/MatchServerEvent.cs @@ -5,6 +5,7 @@ using MessagePack; using osu.Game.Online.Matchmaking.Events; using osu.Game.Online.Multiplayer.Countdown; +using osu.Game.Online.RankedPlay; namespace osu.Game.Online.Multiplayer { @@ -17,6 +18,8 @@ namespace osu.Game.Online.Multiplayer [Union(0, typeof(CountdownStartedEvent))] [Union(1, typeof(CountdownStoppedEvent))] [Union(2, typeof(MatchmakingAvatarActionEvent))] + [Union(3, typeof(RankedPlayCardHandReplayEvent))] + [Union(4, typeof(RollEvent))] public abstract class MatchServerEvent { } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs index b55fa638442c..0c4106ae2b21 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs @@ -28,14 +28,20 @@ public class MatchmakingRoomState : MatchRoomState public int CurrentRound { get; set; } /// - /// The playlist items that were picked as gameplay candidates. + /// The playlist items that were picked as candidates by user. /// + /// + /// May contain -1 when any users picked the "random" playlist item. + /// [Key(2)] public long[] CandidateItems { get; set; } = []; /// - /// The final gameplay candidate. + /// A playlist item from that was randomly picked by the server. /// + /// + /// May be -1 to indicate the "random" playlist item was chosen. + /// [Key(3)] public long CandidateItem { get; set; } @@ -45,6 +51,15 @@ public class MatchmakingRoomState : MatchRoomState [Key(4)] public MatchmakingUserList Users { get; set; } = new MatchmakingUserList(); + /// + /// A playlist item from the room's playlist that will be played in the current round. + /// + /// + /// The value of this property may not equal or exist in . + /// + [Key(5)] + public long GameplayItem { get; set; } + /// /// Advances to the next round. /// diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs index edffa4ec2353..de5344709724 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs @@ -54,6 +54,6 @@ public enum MatchmakingStage /// /// All rounds have completed. Users may still be chatting. /// - Ended + Ended, } } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs index ac97b114d807..94062d6024a0 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs @@ -36,5 +36,11 @@ public class MatchmakingUser /// [Key(3)] public MatchmakingRoundList Rounds { get; set; } = new MatchmakingRoundList(); + + /// + /// The time at which this user abandoned the match. + /// + [Key(4)] + public DateTimeOffset? AbandonedAt { get; set; } } } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserComparer.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserComparer.cs index 74da6a9b2acb..a81c49fe97db 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserComparer.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserComparer.cs @@ -23,42 +23,53 @@ public override int Compare(MatchmakingUser? x, MatchmakingUser? y) ArgumentNullException.ThrowIfNull(x); ArgumentNullException.ThrowIfNull(y); - // X appears earlier in the list if it has more points. - if (x.Points > y.Points) - return -1; + int compare = compareAbandonedAt(x, y); + if (compare != 0) + return compare; - // Y appears earlier in the list if it has more points. - if (y.Points > x.Points) - return 1; + compare = comparePoints(x, y); + if (compare != 0) + return compare; - // Tiebreaker 1 (likely): From each user's point-of-view, their earliest and best placement. - for (int r = 1; r <= rounds; r++) - { - MatchmakingRound? xRound; - x.Rounds.RoundsDictionary.TryGetValue(r, out xRound); + compare = compareRoundPlacements(x, y); + if (compare != 0) + return compare; - MatchmakingRound? yRound; - y.Rounds.RoundsDictionary.TryGetValue(r, out yRound); + return compareUserIds(x, y); + } - // Nothing to do if both players haven't played this round. - if (xRound == null && yRound == null) - continue; + private int compareAbandonedAt(MatchmakingUser x, MatchmakingUser y) + { + DateTimeOffset xAbandonedAt = x.AbandonedAt ?? DateTimeOffset.MaxValue; + DateTimeOffset yAbandonedAt = y.AbandonedAt ?? DateTimeOffset.MaxValue; + return -xAbandonedAt.CompareTo(yAbandonedAt); + } + + private int comparePoints(MatchmakingUser x, MatchmakingUser y) + { + return -x.Points.CompareTo(y.Points); + } - // X appears later in the list if it hasn't played this round. - if (xRound == null) - return 1; + private int compareRoundPlacements(MatchmakingUser x, MatchmakingUser y) + { + for (int r = 1; r <= rounds; r++) + { + x.Rounds.RoundsDictionary.TryGetValue(r, out var xRound); + y.Rounds.RoundsDictionary.TryGetValue(r, out var yRound); - // Y appears later in the list if it hasn't played this round. - if (yRound == null) - return -1; + int xPlacement = xRound?.Placement ?? int.MaxValue; + int yPlacement = yRound?.Placement ?? int.MaxValue; - // X appears earlier in the list if it has a better placement in the round. - int compare = xRound.Placement.CompareTo(yRound.Placement); + int compare = xPlacement.CompareTo(yPlacement); if (compare != 0) return compare; } - // Tiebreaker 2 (unlikely): User ID. + return 0; + } + + private int compareUserIds(MatchmakingUser x, MatchmakingUser y) + { return x.UserId.CompareTo(y.UserId); } } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayCardItem.cs b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayCardItem.cs new file mode 100644 index 000000000000..65cb1987fef9 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayCardItem.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics.CodeAnalysis; +using MessagePack; + +namespace osu.Game.Online.Multiplayer.MatchTypes.RankedPlay +{ + [Serializable] + [MessagePackObject] + public class RankedPlayCardItem : IEquatable + { + /// + /// A unique identifier for this card. + /// + [Key(0)] + public Guid ID { get; set; } = Guid.NewGuid(); + + public bool Equals(RankedPlayCardItem? other) + => other != null && ID.Equals(other.ID); + + public override bool Equals(object? obj) + => obj is RankedPlayCardItem other && Equals(other); + + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] + public override int GetHashCode() + { + return ID.GetHashCode(); + } + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayDamageInfo.cs b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayDamageInfo.cs new file mode 100644 index 000000000000..66ae9fc9a658 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayDamageInfo.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; + +namespace osu.Game.Online.Multiplayer.MatchTypes.RankedPlay +{ + [Serializable] + [MessagePackObject] + public class RankedPlayDamageInfo : IEquatable + { + /// + /// Total amount of damage dealt. + /// + [Key(0)] + public required int Damage { get; init; } + + /// + /// Damage dealt before multipliers are applied. + /// + [Key(1)] + public required int RawDamage { get; init; } + + /// + /// Life before damage was applied. + /// + [Key(2)] + public required int OldLife { get; init; } + + /// + /// Life after damage was applied. + /// + [Key(3)] + public required int NewLife { get; init; } + + public bool Equals(RankedPlayDamageInfo? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + + return Damage == other.Damage && RawDamage == other.RawDamage && OldLife == other.OldLife && NewLife == other.NewLife; + } + + public override bool Equals(object? obj) + { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((RankedPlayDamageInfo)obj); + } + + public override int GetHashCode() => HashCode.Combine(Damage, RawDamage, OldLife, NewLife); + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayRoomState.cs b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayRoomState.cs new file mode 100644 index 000000000000..e303bb0aa504 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayRoomState.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using MessagePack; + +namespace osu.Game.Online.Multiplayer.MatchTypes.RankedPlay +{ + [Serializable] + [MessagePackObject] + public class RankedPlayRoomState : MatchRoomState + { + /// + /// The current room stage. + /// + [Key(0)] + public RankedPlayStage Stage { get; set; } + + /// + /// The current round number (1-based). + /// + [Key(1)] + public int CurrentRound { get; set; } + + /// + /// A multiplier applied to life point damage. + /// + [Key(2)] + public double DamageMultiplier { get; set; } = 1; + + /// + /// A dictionary containing all users in the room. + /// + [Key(3)] + public Dictionary Users { get; set; } = []; + + /// + /// The ID of the user currently playing a card. + /// + [Key(4)] + public int? ActiveUserId { get; set; } + + /// + /// The average star rating of all cards. + /// + [Key(5)] + public double StarRating { get; set; } + + /// + /// The winner of the match. + /// + [Key(6)] + public int? WinningUserId { get; set; } + + /// + /// The user currently playing a card. + /// + [IgnoreMember] + public RankedPlayUserInfo? ActiveUser => ActiveUserId == null ? null : Users[ActiveUserId.Value]; + + [IgnoreMember] + public RankedPlayUserInfo? WinningUser => WinningUserId == null ? null : Users[WinningUserId.Value]; + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayStage.cs b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayStage.cs new file mode 100644 index 000000000000..1a23b5224fa4 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayStage.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. + +namespace osu.Game.Online.Multiplayer.MatchTypes.RankedPlay +{ + public enum RankedPlayStage + { + /// + /// Waiting for clients to join. + /// + WaitForJoin, + + /// + /// Period of time before the round starts. + /// + RoundWarmup, + + /// + /// Users are discarding cards and drawing new ones. + /// + CardDiscard, + + /// + /// Users have finished discarding their cards. + /// + FinishCardDiscard, + + /// + /// The active user is selecting a card to play. + /// + CardPlay, + + /// + /// The active user has made a selection, both players should now start downloading it. + /// + FinishCardPlay, + + /// + /// Period of time before gameplay starts. + /// + GameplayWarmup, + + /// + /// Gameplay is in progress. + /// + Gameplay, + + /// + /// Users are viewing the gameplay results + /// + Results, + + /// + /// The match has concluded. + /// + Ended + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayUserInfo.cs b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayUserInfo.cs new file mode 100644 index 000000000000..a370ef194c8c --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/RankedPlay/RankedPlayUserInfo.cs @@ -0,0 +1,47 @@ +// 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 MessagePack; + +namespace osu.Game.Online.Multiplayer.MatchTypes.RankedPlay +{ + [Serializable] + [MessagePackObject] + public class RankedPlayUserInfo + { + /// + /// This user's matchmaking rating. + /// + [Key(0)] + public required int Rating { get; set; } + + /// + /// The current life points. + /// + [Key(1)] + public int Life { get; set; } = 1_000_000; + + /// + /// The cards in this user's hand. + /// + [Key(2)] + public List Hand { get; set; } = []; + + /// + /// Rating after conclusion of the match. + /// + [Key(3)] + public int RatingAfter { get; set; } + + /// + /// Information about damage being applied in the current stage. + /// + /// + /// This value is only expected to be populated during the stage. + /// + [Key(4)] + public RankedPlayDamageInfo? DamageInfo; + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs index 375842964395..d5e30bb2e08b 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs @@ -12,6 +12,9 @@ public class TeamVersusRoomState : MatchRoomState [Key(0)] public List Teams { get; set; } = new List(); + [Key(1)] + public bool Locked { get; set; } + public static TeamVersusRoomState CreateDefault() => new TeamVersusRoomState { diff --git a/osu.Game/Online/Multiplayer/MatchUserRequest.cs b/osu.Game/Online/Multiplayer/MatchUserRequest.cs index 02704ea161d4..bacc1a7632b4 100644 --- a/osu.Game/Online/Multiplayer/MatchUserRequest.cs +++ b/osu.Game/Online/Multiplayer/MatchUserRequest.cs @@ -6,6 +6,7 @@ using osu.Game.Online.Matchmaking.Events; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Online.RankedPlay; namespace osu.Game.Online.Multiplayer { @@ -19,6 +20,9 @@ namespace osu.Game.Online.Multiplayer [Union(1, typeof(StartMatchCountdownRequest))] [Union(2, typeof(StopCountdownRequest))] [Union(3, typeof(MatchmakingAvatarActionRequest))] + [Union(4, typeof(RankedPlayCardHandReplayRequest))] + [Union(5, typeof(SetLockStateRequest))] + [Union(6, typeof(RollRequest))] public abstract class MatchUserRequest { } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index df16022e59e0..ff974e2e6dd5 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -19,15 +19,22 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer.Countdown; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; using osu.Game.Online.Rooms; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; using osu.Game.Utils; namespace osu.Game.Online.Multiplayer { - public abstract partial class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer, IMatchmakingServer, IMatchmakingClient + public abstract partial class MultiplayerClient : + Component, + IMultiplayerClient, IMultiplayerRoomServer, + IMatchmakingServer, IMatchmakingClient, + IRankedPlayClient, IRankedPlayServer { public Action? PostNotification { protected get; set; } @@ -123,7 +130,7 @@ public abstract partial class MultiplayerClient : Component, IMultiplayerClient, public event Action? MatchmakingQueueJoined; public event Action? MatchmakingQueueLeft; - public event Action? MatchmakingRoomInvited; + public event Action? MatchmakingRoomInvited; public event Action? MatchmakingRoomReady; public event Action? MatchmakingLobbyStatusChanged; public event Action? MatchmakingQueueStatusChanged; @@ -131,6 +138,13 @@ public abstract partial class MultiplayerClient : Component, IMultiplayerClient, public event Action? MatchmakingItemDeselected; public event Action? MatchRoomStateChanged; + public event Action? RankedPlayCardAdded; + public event Action? RankedPlayCardRemoved; + public event Action? RankedPlayCardPlayed; + + public event Action? UserVotedToSkipIntro; + public event Action? VoteToSkipIntroPassed; + public event Action? BeatmapAvailabilityChanged; /// @@ -194,6 +208,7 @@ public virtual bool IsHost protected Room? APIRoom { get; private set; } private readonly Queue pendingRequests = new Queue(); + private readonly Dictionary cardsWithPlaylistItems = []; [BackgroundDependencyLoader] private void load() @@ -316,9 +331,6 @@ protected virtual void OnRoomJoined() public Task LeaveRoom() { - if (Room == null) - return Task.CompletedTask; - // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled. // This includes the setting of Room itself along with the initial update of the room settings on join. joinCancellationSource?.Cancel(); @@ -331,6 +343,7 @@ public Task LeaveRoom() APIRoom = null; Room = null; PlayingUserIds.Clear(); + cardsWithPlaylistItems.Clear(); RoomUpdated?.Invoke(); }); @@ -495,6 +508,8 @@ public async Task ToggleSpectate() public abstract Task RemovePlaylistItem(long playlistItemId); + public abstract Task VoteToSkipIntro(); + Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) { handleRoomRequest(() => @@ -919,6 +934,36 @@ public Task PlaylistItemChanged(MultiplayerPlaylistItem item) return Task.CompletedTask; } + Task IMultiplayerClient.UserVotedToSkipIntro(int userId, bool voted) + { + handleRoomRequest(() => + { + Debug.Assert(Room != null); + + var user = Room.Users.SingleOrDefault(u => u.UserID == userId); + + // TODO: user should NEVER be null here, see https://github.com/ppy/osu/issues/17713. + if (user == null) + return; + + user.VotedToSkipIntro = voted; + UserVotedToSkipIntro?.Invoke(userId, voted); + }); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.VoteToSkipIntroPassed() + { + handleRoomRequest(() => + { + Debug.Assert(Room != null); + VoteToSkipIntroPassed?.Invoke(); + }); + + return Task.CompletedTask; + } + /// /// Populates the for a given collection of s. /// @@ -1053,7 +1098,13 @@ Task IMatchmakingClient.MatchmakingQueueLeft() Task IMatchmakingClient.MatchmakingRoomInvited() { - Scheduler.Add(() => MatchmakingRoomInvited?.Invoke()); + // Not implemented (used by older clients). + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingRoomInvitedWithParams(MatchmakingRoomInvitationParams invitation) + { + Scheduler.Add(() => MatchmakingRoomInvited?.Invoke(invitation)); return Task.CompletedTask; } @@ -1077,7 +1128,7 @@ Task IMatchmakingClient.MatchmakingQueueStatusChanged(MatchmakingQueueStatus sta Task IMatchmakingClient.MatchmakingItemSelected(int userId, long playlistItemId) { - Scheduler.Add(() => + handleRoomRequest(() => { MatchmakingItemSelected?.Invoke(userId, playlistItemId); RoomUpdated?.Invoke(); @@ -1088,7 +1139,7 @@ Task IMatchmakingClient.MatchmakingItemSelected(int userId, long playlistItemId) Task IMatchmakingClient.MatchmakingItemDeselected(int userId, long playlistItemId) { - Scheduler.Add(() => + handleRoomRequest(() => { MatchmakingItemDeselected?.Invoke(userId, playlistItemId); RoomUpdated?.Invoke(); @@ -1097,7 +1148,63 @@ Task IMatchmakingClient.MatchmakingItemDeselected(int userId, long playlistItemI return Task.CompletedTask; } - public abstract Task GetMatchmakingPools(); + public abstract Task DiscardCards(RankedPlayCardItem[] cards); + + public abstract Task PlayCard(RankedPlayCardItem card); + + Task IRankedPlayClient.RankedPlayCardAdded(int userId, RankedPlayCardItem card) + { + handleRoomRequest(() => + { + RankedPlayCardAdded?.Invoke(userId, GetCardWithPlaylistItem(card)); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IRankedPlayClient.RankedPlayCardRemoved(int userId, RankedPlayCardItem card) + { + handleRoomRequest(() => + { + RankedPlayCardRemoved?.Invoke(userId, GetCardWithPlaylistItem(card)); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IRankedPlayClient.RankedPlayCardRevealed(RankedPlayCardItem card, MultiplayerPlaylistItem item) + { + handleRoomRequest(() => + { + GetCardWithPlaylistItem(card).PlaylistItem.Value = item; + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IRankedPlayClient.RankedPlayCardPlayed(RankedPlayCardItem card) + { + handleRoomRequest(() => + { + RankedPlayCardPlayed?.Invoke(GetCardWithPlaylistItem(card)); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + public RankedPlayCardWithPlaylistItem GetCardWithPlaylistItem(RankedPlayCardItem card) + { + if (cardsWithPlaylistItems.TryGetValue(card, out var existing)) + return existing; + + return cardsWithPlaylistItems[card] = new RankedPlayCardWithPlaylistItem(card); + } + + public abstract Task GetMatchmakingPoolsOfType(MatchmakingPoolType type); public abstract Task MatchmakingJoinLobby(); diff --git a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs index 1cc5a8e70a80..83d9e64a360d 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.SignalR; using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Logging; +using osu.Game.Utils; namespace osu.Game.Online.Multiplayer { @@ -20,13 +21,21 @@ public static void FireAndForget(this Task task, Action? onSuccess = null, Actio Debug.Assert(t.Exception != null); Exception exception = t.Exception.AsSingular(); + onError?.Invoke(exception); + + // OnlineStatusNotifier is already letting users know about interruptions to connections. + // Silence these because it gets very spammy otherwise. + if (SentryLogger.IsLocalUserConnectivityException(exception)) + return; + if (exception.GetHubExceptionMessage() is string message) + { // Hub exceptions generally contain something we can show the user directly. Logger.Log(message, level: LogLevel.Important); - else - Logger.Error(exception, $"Unobserved exception occurred via {nameof(FireAndForget)} call: {exception.Message}"); + return; + } - onError?.Invoke(exception); + Logger.Error(exception, $"Unobserved exception occurred via {nameof(FireAndForget)} call: {exception.Message}"); } else { diff --git a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs index bc2536848be3..9f4d7f1039b8 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs @@ -5,6 +5,7 @@ using MessagePack; using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer.Countdown; +using osu.Game.Online.RankedPlay; namespace osu.Game.Online.Multiplayer { @@ -16,6 +17,7 @@ namespace osu.Game.Online.Multiplayer [Union(1, typeof(ForceGameplayStartCountdown))] [Union(2, typeof(ServerShuttingDownCountdown))] [Union(3, typeof(MatchmakingStageCountdown))] + [Union(4, typeof(RankedPlayStageCountdown))] public abstract class MultiplayerCountdown { /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index 499e84ce80d8..3bf15a781fd5 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs @@ -49,6 +49,18 @@ public class MultiplayerRoomUser : IEquatable [Key(6)] public int? BeatmapId; + /// + /// Whether this user voted to skip the beatmap intro. + /// + [Key(7)] + public bool VotedToSkipIntro; + + /// + /// The role of this user in the room. + /// + [Key(8)] + public MultiplayerRoomUserRole Role; + [IgnoreMember] public APIUser? User { get; set; } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUserRole.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUserRole.cs new file mode 100644 index 000000000000..edef4f4c358f --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUserRole.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. + +namespace osu.Game.Online.Multiplayer +{ + public enum MultiplayerRoomUserRole + { + Player, + Referee, + } +} diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index 0decff7ab3e1..6d722807aa41 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -15,6 +15,8 @@ using osu.Game.Overlays.Notifications; using osu.Game.Localisation; using osu.Game.Online.Matchmaking; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; namespace osu.Game.Online.Multiplayer { @@ -70,16 +72,24 @@ private void load(IAPIProvider api) connection.On(nameof(IMultiplayerClient.PlaylistItemAdded), ((IMultiplayerClient)this).PlaylistItemAdded); connection.On(nameof(IMultiplayerClient.PlaylistItemRemoved), ((IMultiplayerClient)this).PlaylistItemRemoved); connection.On(nameof(IMultiplayerClient.PlaylistItemChanged), ((IMultiplayerClient)this).PlaylistItemChanged); - connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested); + connection.On(nameof(IMultiplayerClient.UserVotedToSkipIntro), ((IMultiplayerClient)this).UserVotedToSkipIntro); + connection.On(nameof(IMultiplayerClient.VoteToSkipIntroPassed), ((IMultiplayerClient)this).VoteToSkipIntroPassed); connection.On(nameof(IMatchmakingClient.MatchmakingQueueJoined), ((IMatchmakingClient)this).MatchmakingQueueJoined); connection.On(nameof(IMatchmakingClient.MatchmakingQueueLeft), ((IMatchmakingClient)this).MatchmakingQueueLeft); - connection.On(nameof(IMatchmakingClient.MatchmakingRoomInvited), ((IMatchmakingClient)this).MatchmakingRoomInvited); + connection.On(nameof(IMatchmakingClient.MatchmakingRoomInvitedWithParams), ((IMatchmakingClient)this).MatchmakingRoomInvitedWithParams); connection.On(nameof(IMatchmakingClient.MatchmakingRoomReady), ((IMatchmakingClient)this).MatchmakingRoomReady); connection.On(nameof(IMatchmakingClient.MatchmakingLobbyStatusChanged), ((IMatchmakingClient)this).MatchmakingLobbyStatusChanged); connection.On(nameof(IMatchmakingClient.MatchmakingQueueStatusChanged), ((IMatchmakingClient)this).MatchmakingQueueStatusChanged); connection.On(nameof(IMatchmakingClient.MatchmakingItemSelected), ((IMatchmakingClient)this).MatchmakingItemSelected); connection.On(nameof(IMatchmakingClient.MatchmakingItemDeselected), ((IMatchmakingClient)this).MatchmakingItemDeselected); + + connection.On(nameof(IRankedPlayClient.RankedPlayCardAdded), ((IRankedPlayClient)this).RankedPlayCardAdded); + connection.On(nameof(IRankedPlayClient.RankedPlayCardRemoved), ((IRankedPlayClient)this).RankedPlayCardRemoved); + connection.On(nameof(IRankedPlayClient.RankedPlayCardRevealed), ((IRankedPlayClient)this).RankedPlayCardRevealed); + connection.On(nameof(IRankedPlayClient.RankedPlayCardPlayed), ((IRankedPlayClient)this).RankedPlayCardPlayed); + + connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested); }; IsConnected.BindTo(connector.IsConnected); @@ -312,6 +322,16 @@ public override Task RemovePlaylistItem(long playlistItemId) return connection.InvokeAsync(nameof(IMultiplayerServer.RemovePlaylistItem), playlistItemId); } + public override Task VoteToSkipIntro() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IMultiplayerServer.VoteToSkipIntro)); + } + public override Task DisconnectInternal() { if (connector == null) @@ -320,13 +340,33 @@ public override Task DisconnectInternal() return connector.Disconnect(); } - public override Task GetMatchmakingPools() + public override Task DiscardCards(RankedPlayCardItem[] cards) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IRankedPlayServer.DiscardCards), cards); + } + + public override Task PlayCard(RankedPlayCardItem card) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IRankedPlayServer.PlayCard), card); + } + + public override Task GetMatchmakingPoolsOfType(MatchmakingPoolType type) { if (!IsConnected.Value) return Task.FromResult(Array.Empty()); Debug.Assert(connection != null); - return connection.InvokeAsync(nameof(IMatchmakingServer.GetMatchmakingPools)); + return connection.InvokeAsync(nameof(IMatchmakingServer.GetMatchmakingPoolsOfType), type); } public override Task MatchmakingJoinLobby() diff --git a/osu.Game/Online/Multiplayer/RollEvent.cs b/osu.Game/Online/Multiplayer/RollEvent.cs new file mode 100644 index 000000000000..8588bc6adf1a --- /dev/null +++ b/osu.Game/Online/Multiplayer/RollEvent.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 System; +using MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// Communicates the result of a . + /// + [Serializable] + [MessagePackObject] + public class RollEvent : MatchServerEvent + { + /// + /// The ID of the user who initiated the roll. + /// + [Key(0)] + public int UserID { get; set; } + + /// + /// Determines the maximum possible result of the roll. + /// Bigger than 1. + /// + [Key(1)] + public uint Max { get; set; } + + /// + /// The actual result of the roll. + /// In the range [1, ], inclusive both ends. + /// + [Key(2)] + public uint Result { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/RollRequest.cs b/osu.Game/Online/Multiplayer/RollRequest.cs new file mode 100644 index 000000000000..3f6be23e1e87 --- /dev/null +++ b/osu.Game/Online/Multiplayer/RollRequest.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 System; +using MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// Requests a random roll of a number from 1 to inclusive. + /// + [Serializable] + [MessagePackObject] + public class RollRequest : MatchUserRequest + { + /// + /// Determines the maximum possible result of the roll. + /// Must be bigger than 1. + /// Defaults to 100 if not provided. + /// + [Key(0)] + public uint? Max { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs b/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs index 1de18e44a758..5a6367b06b33 100644 --- a/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs +++ b/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs @@ -5,6 +5,7 @@ using Humanizer.Localisation; using osu.Framework.Allocation; using osu.Framework.Threading; +using osu.Game.Localisation; using osu.Game.Overlays.Notifications; using osu.Game.Utils; @@ -53,10 +54,10 @@ private void updateTime() if (remaining.TotalSeconds <= 5) { updateDelegate?.Cancel(); - Text = "The multiplayer server will be right back..."; + Text = NotificationsStrings.MultiplayerServerShuttingDownImmediately; } else - Text = $"The multiplayer server is restarting in {HumanizerUtils.Humanize(remaining, precision: 3, minUnit: TimeUnit.Second)}."; + Text = NotificationsStrings.MultiplayerServerShuttingDownRemaining(HumanizerUtils.Humanize(remaining, precision: 3, minUnit: TimeUnit.Second)); } } } diff --git a/osu.Game/Online/Multiplayer/SetLockStateRequest.cs b/osu.Game/Online/Multiplayer/SetLockStateRequest.cs new file mode 100644 index 000000000000..8f1451fdab7a --- /dev/null +++ b/osu.Game/Online/Multiplayer/SetLockStateRequest.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 MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + [MessagePackObject] + public class SetLockStateRequest : MatchUserRequest + { + /// + /// + /// If , s will not be able to change teams by themselves in the room, + /// only s will be able to change teams for the s. + /// + /// + /// If , any user can change their team in the room. + /// + /// + // TODO: mention slots as well when slots are reimplemented + [Key(0)] + public bool Locked { get; set; } + } +} diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index 10d766c729e4..f4c33c67aa30 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; @@ -16,6 +17,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.Play; namespace osu.Game.Online { @@ -74,22 +76,16 @@ protected override void LoadComplete() apiState.BindValueChanged(state => { - if (state.NewValue == APIState.Online) + switch (state.NewValue) { - userNotified = false; - return; - } - - if (userNotified) return; - - if (state.NewValue == APIState.Offline && getCurrentScreen() is OnlinePlayScreen) - { - userNotified = true; - notificationOverlay?.Post(new SimpleErrorNotification - { - Icon = FontAwesome.Solid.ExclamationCircle, - Text = "Connection to API was lost. Can't continue with online play." - }); + case APIState.Online: + userNotified = false; + return; + + case APIState.Offline: + if (getCurrentScreen() is OnlinePlayScreen) + notifyApiDisconnection(); + break; } }); @@ -101,22 +97,37 @@ protected override void LoadComplete() return; } - if (userNotified) return; - if (multiplayerClient.Room != null) + notifyApiDisconnection(); + })); + + spectatorState.BindValueChanged(connected => Schedule(() => + { + if (connected.NewValue) { - userNotified = true; - notificationOverlay?.Post(new SimpleErrorNotification - { - Icon = FontAwesome.Solid.ExclamationCircle, - Text = "Connection to the multiplayer server was lost. Exiting multiplayer." - }); + userNotified = false; + return; + } + + switch (getCurrentScreen()) + { + case SpectatorPlayer: // obvious issues + case SubmittingPlayer: // replay sending issues + notifyApiDisconnection(); + break; } })); + } - spectatorState.BindValueChanged(_ => + private void notifyApiDisconnection() + { + if (userNotified) return; + + userNotified = true; + notificationOverlay?.Post(new SimpleErrorNotification { - // TODO: handle spectator server failure somehow? + Icon = FontAwesome.Solid.ExclamationCircle, + Text = NotificationsStrings.APIConnectionInterrupted, }); } @@ -128,7 +139,7 @@ private void notifyAboutForcedDisconnection() notificationOverlay?.Post(new SimpleErrorNotification { Icon = FontAwesome.Solid.ExclamationCircle, - Text = "You have been logged out on this device due to a login to your account on another device." + Text = NotificationsStrings.AnotherDeviceDisconnect, }); } @@ -142,7 +153,7 @@ private void notifyAboutForcedDisconnection(SocketMessage obj) notificationOverlay?.Post(new SimpleErrorNotification { Icon = FontAwesome.Solid.ExclamationCircle, - Text = "You have been logged out due to a change to your account. Please log in again." + Text = NotificationsStrings.AccountChangeDisconnect, }); } diff --git a/osu.Game/Online/ProductionEndpointConfiguration.cs b/osu.Game/Online/ProductionEndpointConfiguration.cs index 20583c8c7ee1..37ce2b64e127 100644 --- a/osu.Game/Online/ProductionEndpointConfiguration.cs +++ b/osu.Game/Online/ProductionEndpointConfiguration.cs @@ -10,9 +10,9 @@ public ProductionEndpointConfiguration() WebsiteUrl = APIUrl = @"https://osu.ppy.sh"; APIClientSecret = @"FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk"; APIClientID = "5"; - SpectatorUrl = "https://spectator.ppy.sh/spectator"; - MultiplayerUrl = "https://spectator.ppy.sh/multiplayer"; - MetadataUrl = "https://spectator.ppy.sh/metadata"; + SpectatorUrl = "https://spectator.osu.ppy.sh/spectator"; + MultiplayerUrl = "https://spectator.osu.ppy.sh/multiplayer"; + MetadataUrl = "https://spectator.osu.ppy.sh/metadata"; BeatmapSubmissionServiceUrl = "https://bss.ppy.sh"; } } diff --git a/osu.Game/Online/RankedPlay/IRankedPlayClient.cs b/osu.Game/Online/RankedPlay/IRankedPlayClient.cs new file mode 100644 index 000000000000..ea4f9d55810b --- /dev/null +++ b/osu.Game/Online/RankedPlay/IRankedPlayClient.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; + +namespace osu.Game.Online.RankedPlay +{ + public interface IRankedPlayClient + { + /// + /// Indicates that a card has been added to a user's hand. + /// + /// The user whose hand has changed. + /// The card added to the user's hand. + Task RankedPlayCardAdded(int userId, RankedPlayCardItem card); + + /// + /// Indicates that a card has been removed from a user's hand. + /// + /// The user whose hand has changed. + /// The card removed from the user's hand. + Task RankedPlayCardRemoved(int userId, RankedPlayCardItem card); + + /// + /// Indicates that a card has been revealed to the local user. + /// + /// The card that was revealed. + /// The playlist item the card corresponds to. + Task RankedPlayCardRevealed(RankedPlayCardItem card, MultiplayerPlaylistItem item); + + /// + /// Indicates a card was played. + /// + /// The card played. + Task RankedPlayCardPlayed(RankedPlayCardItem card); + } +} diff --git a/osu.Game/Online/RankedPlay/IRankedPlayServer.cs b/osu.Game/Online/RankedPlay/IRankedPlayServer.cs new file mode 100644 index 000000000000..2cdbcabc8ba8 --- /dev/null +++ b/osu.Game/Online/RankedPlay/IRankedPlayServer.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; + +namespace osu.Game.Online.RankedPlay +{ + public interface IRankedPlayServer + { + /// + /// Discards cards from the local user's hand during the stage. + /// + Task DiscardCards(RankedPlayCardItem[] cards); + + /// + /// Plays a card from the local user's hand during the stage. + /// Only usable while the local user is the active player. + /// + Task PlayCard(RankedPlayCardItem card); + } +} diff --git a/osu.Game/Online/RankedPlay/RankedPlayCardHandReplayEvent.cs b/osu.Game/Online/RankedPlay/RankedPlayCardHandReplayEvent.cs new file mode 100644 index 000000000000..9c4d0afe31b9 --- /dev/null +++ b/osu.Game/Online/RankedPlay/RankedPlayCardHandReplayEvent.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Online.RankedPlay +{ + [Serializable] + [MessagePackObject] + public class RankedPlayCardHandReplayEvent : MatchServerEvent + { + /// + /// The user performing the action. + /// + [Key(0)] + public int UserId { get; set; } + + [Key(1)] + public required RankedPlayCardHandReplayFrame[] Frames { get; init; } + } +} diff --git a/osu.Game/Online/RankedPlay/RankedPlayCardHandReplayFrame.cs b/osu.Game/Online/RankedPlay/RankedPlayCardHandReplayFrame.cs new file mode 100644 index 000000000000..de6b312b91be --- /dev/null +++ b/osu.Game/Online/RankedPlay/RankedPlayCardHandReplayFrame.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using MessagePack; + +namespace osu.Game.Online.RankedPlay +{ + [Serializable] + [MessagePackObject] + public readonly record struct RankedPlayCardHandReplayFrame + { + /// + /// Duration in milliseconds since the previous frame. + /// + [Key(0)] + public required double Delay { get; init; } + + /// + /// Dictionary containing the state of each card. + /// + [Key(1)] + public required Dictionary Cards { get; init; } + + /// + /// Creates a replay frame that only contains state entries that differ from the previous frame + /// + public RankedPlayCardHandReplayFrame RelativeTo(RankedPlayCardHandReplayFrame other) => this with + { + Cards = Cards.Where(entry => !other.Cards.Contains(entry)).ToDictionary(), + }; + } +} diff --git a/osu.Game/Online/RankedPlay/RankedPlayCardHandReplayRequest.cs b/osu.Game/Online/RankedPlay/RankedPlayCardHandReplayRequest.cs new file mode 100644 index 000000000000..1bfbfe1cd4a7 --- /dev/null +++ b/osu.Game/Online/RankedPlay/RankedPlayCardHandReplayRequest.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 System; +using MessagePack; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Online.RankedPlay +{ + [Serializable] + [MessagePackObject] + public class RankedPlayCardHandReplayRequest : MatchUserRequest + { + [Key(0)] + public required RankedPlayCardHandReplayFrame[] Frames { get; init; } + } +} diff --git a/osu.Game/Online/RankedPlay/RankedPlayCardState.cs b/osu.Game/Online/RankedPlay/RankedPlayCardState.cs new file mode 100644 index 000000000000..bd938fb9db23 --- /dev/null +++ b/osu.Game/Online/RankedPlay/RankedPlayCardState.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; + +namespace osu.Game.Online.RankedPlay +{ + [Serializable] + [MessagePackObject] + public readonly record struct RankedPlayCardState + { + [Key(0)] + public required bool Hovered { get; init; } + + [Key(1)] + public required bool Pressed { get; init; } + + [Key(2)] + public required bool Selected { get; init; } + } +} diff --git a/osu.Game/Online/RankedPlay/RankedPlayStageCountdown.cs b/osu.Game/Online/RankedPlay/RankedPlayStageCountdown.cs new file mode 100644 index 000000000000..541fbb31c357 --- /dev/null +++ b/osu.Game/Online/RankedPlay/RankedPlayStageCountdown.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; + +namespace osu.Game.Online.RankedPlay +{ + [MessagePackObject] + public class RankedPlayStageCountdown : MultiplayerCountdown + { + [Key(2)] + public RankedPlayStage Stage { get; set; } + } +} diff --git a/osu.Game/Online/Rooms/MatchType.cs b/osu.Game/Online/Rooms/MatchType.cs index bbfe25c8fd3c..875748da1ad5 100644 --- a/osu.Game/Online/Rooms/MatchType.cs +++ b/osu.Game/Online/Rooms/MatchType.cs @@ -18,6 +18,30 @@ public enum MatchType [LocalisableDescription(typeof(MatchesStrings), nameof(MatchesStrings.MatchTeamTypesTeamVersus))] TeamVersus, - Matchmaking + /// + /// Matchmaking: Quick play + /// + Matchmaking, + + /// + /// Matchmaking: Ranked play + /// + RankedPlay + } + + public static class MatchTypeExtensions + { + public static bool IsMatchmakingType(this MatchType type) + { + switch (type) + { + case MatchType.Matchmaking: + case MatchType.RankedPlay: + return true; + + default: + return false; + } + } } } diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index e50989148657..06e8451205b2 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -8,7 +8,9 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Online.RankedPlay; using osu.Game.Users; namespace osu.Game.Online @@ -25,8 +27,11 @@ internal static class SignalRWorkaroundTypes (typeof(ChangeTeamRequest), typeof(MatchUserRequest)), (typeof(StartMatchCountdownRequest), typeof(MatchUserRequest)), (typeof(StopCountdownRequest), typeof(MatchUserRequest)), + (typeof(SetLockStateRequest), typeof(MatchUserRequest)), + (typeof(RollRequest), typeof(MatchUserRequest)), (typeof(CountdownStartedEvent), typeof(MatchServerEvent)), (typeof(CountdownStoppedEvent), typeof(MatchServerEvent)), + (typeof(RollEvent), typeof(MatchServerEvent)), (typeof(TeamVersusRoomState), typeof(MatchRoomState)), (typeof(TeamVersusUserState), typeof(MatchUserState)), (typeof(MatchStartCountdown), typeof(MultiplayerCountdown)), @@ -57,6 +62,12 @@ internal static class SignalRWorkaroundTypes (typeof(MatchmakingStageCountdown), typeof(MultiplayerCountdown)), (typeof(MatchmakingAvatarActionRequest), typeof(MatchUserRequest)), (typeof(MatchmakingAvatarActionEvent), typeof(MatchServerEvent)), + + // ranked play + (typeof(RankedPlayRoomState), typeof(MatchRoomState)), + (typeof(RankedPlayStageCountdown), typeof(MultiplayerCountdown)), + (typeof(RankedPlayCardHandReplayRequest), typeof(MatchUserRequest)), + (typeof(RankedPlayCardHandReplayEvent), typeof(MatchServerEvent)), }; } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 4ea9fae1838b..77ccf7edfc10 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -69,9 +69,9 @@ using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Leaderboards; using osu.Game.Seasonal; using osu.Game.Skinning; using osu.Game.Updater; @@ -171,7 +171,7 @@ public partial class OsuGame : OsuGameBase, IKeyBindingHandler, IL [Cached] private readonly ScreenshotManager screenshotManager = new ScreenshotManager(); - protected SentryLogger SentryLogger; + private SentryLogger sentryLogger; public virtual StableStorage GetStorageForStableInstall() => null; @@ -352,7 +352,7 @@ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnl public override void SetupLogging(Storage gameStorage, Storage cacheStorage) { base.SetupLogging(gameStorage, cacheStorage); - SentryLogger = new SentryLogger(this, cacheStorage); + sentryLogger = new SentryLogger(this, cacheStorage); } public override void SetHost(GameHost host) @@ -404,7 +404,7 @@ void handlePendingDragDropImports() [BackgroundDependencyLoader] private void load() { - SentryLogger.AttachUser(API.LocalUser); + sentryLogger.AttachUser(API.LocalUser); if (SeasonalUIConfig.ENABLED) dependencies.CacheAs(osuLogo = new OsuLogoChristmas { Alpha = 0 }); @@ -762,7 +762,7 @@ public void PresentBeatmap(IBeatmapSetInfo beatmap, Predicate diffi } }, validScreens: new[] { - typeof(SongSelect), typeof(Screens.SelectV2.SongSelect), typeof(IHandlePresentBeatmap) + typeof(SongSelect), typeof(IHandlePresentBeatmap) }); } @@ -865,7 +865,7 @@ public void PresentScore(IScoreInfo score, ScorePresentType presentType = ScoreP // which may not match the score, and thus crash. IEnumerable validScreens = Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap) && Ruleset.Value.Equals(databasedScore.ScoreInfo.Ruleset) - ? new[] { typeof(SongSelect), typeof(Screens.SelectV2.SongSelect), typeof(DailyChallenge) } + ? new[] { typeof(SongSelect), typeof(DailyChallenge) } : Array.Empty(); PerformFromScreen(screen => @@ -1027,7 +1027,7 @@ protected override void Dispose(bool isDisposing) base.Dispose(isDisposing); - SentryLogger.Dispose(); + sentryLogger.Dispose(); if (Host?.Window != null) Host.Window.DragDrop -= onWindowDragDrop; @@ -1139,6 +1139,7 @@ protected override void LoadComplete() }, new PopoverContainer { + // Ensure the footer is displayed above any content and/or overlays. Depth = -1, RelativeSizeAxes = Axes.Both, Child = screenStackFooter = new ScreenStackFooter(ScreenStack, backReceptor) @@ -1380,10 +1381,17 @@ private void forwardGeneralLogToNotifications(LogEntry entry) if (generalLogRecentCount < short_term_display_limit) { + LocalisableString message; + + if (entry.Exception != null && IsDeployedBuild) + message = LocalisableString.Interpolate($"{entry.Message.Truncate(256)}\n\n{NotificationsStrings.ErrorAutomaticallyReported}"); + else + message = entry.Message.Truncate(256); + Schedule(() => Notifications.Post(new SimpleErrorNotification { Icon = entry.Level == LogLevel.Important ? FontAwesome.Solid.ExclamationCircle : FontAwesome.Solid.Bomb, - Text = entry.Message.Truncate(256) + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), + Text = message })); } else if (generalLogRecentCount == short_term_display_limit) @@ -1578,6 +1586,20 @@ public bool OnPressed(KeyBindingPressEvent e) SkinManager.SelectRandomSkin(); return true; + + case GlobalAction.NextSkin: + if (skinEditor.State.Value == Visibility.Visible) + return false; + + SkinManager.SelectNextSkin(); + return true; + + case GlobalAction.PreviousSkin: + if (skinEditor.State.Value == Visibility.Visible) + return false; + + SkinManager.SelectPreviousSkin(); + return true; } return false; diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 222427cb60da..f6fa1147ea4e 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -625,7 +625,7 @@ public virtual SettingsSubsection CreateSettingsSubsectionFor(InputHandler handl return new TouchSettings(th); case MidiHandler: - return new InputSection.HandlerSection(handler); + return new InputSubsection(handler); // return null for handlers that shouldn't have settings. default: @@ -648,16 +648,16 @@ private void onRulesetChanged(ValueChangedEvent r) Ruleset instance = null; - try + if (r.NewValue?.Available == true) { - if (r.NewValue?.Available == true) + try { instance = r.NewValue.CreateInstance(); } - } - catch (Exception e) - { - Logger.Error(e, "Ruleset load failed and has been rolled back"); + catch (Exception e) + { + Rulesets.RulesetStore.LogRulesetFailure(r.NewValue, e); + } } if (instance == null) @@ -682,7 +682,7 @@ private void onRulesetChanged(ValueChangedEvent r) } catch (Exception e) { - Logger.Error(e, $"Could not load mods for \"{instance.RulesetInfo.Name}\" ruleset. Current ruleset has been rolled back."); + Rulesets.RulesetStore.LogRulesetFailure(r.NewValue, e); revertRulesetChange(); return; } diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index b2b672342ed5..c801e9304f49 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -209,13 +209,11 @@ private void performRegistration() passwordDescription.AddErrors(errors.User.Password); } - if (!string.IsNullOrEmpty(errors.Redirect)) - { - if (!string.IsNullOrEmpty(errors.Message)) - passwordDescription.AddErrors(new[] { errors.Message }); + if (!string.IsNullOrEmpty(errors.Message)) + passwordDescription.AddErrors(new[] { errors.Message }); + if (!string.IsNullOrEmpty(errors.Redirect)) game?.OpenUrlExternally($"{errors.Redirect}?username={usernameTextBox.Text}&email={emailTextBox.Text}", LinkWarnMode.NeverWarn); - } } else { diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs index c24bd32bb425..d9c8a20470c3 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs @@ -71,7 +71,7 @@ private void load(OsuColour colours, TextureStore textures) Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Padding = new MarginPadding(20), - Spacing = new Vector2(0, 5), + Spacing = new Vector2(0, 7), Children = new Drawable[] { new Container diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Overlays/BeatmapSet/AdvancedStats.cs similarity index 99% rename from osu.Game/Screens/Select/Details/AdvancedStats.cs rename to osu.Game/Overlays/BeatmapSet/AdvancedStats.cs index 2d105ae382a4..ae2b421edce4 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Overlays/BeatmapSet/AdvancedStats.cs @@ -4,36 +4,36 @@ #nullable disable using System; -using osuTK.Graphics; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Beatmaps; -using osu.Framework.Bindables; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using osu.Game.Rulesets.Mods; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Framework.Utils; +using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Mods; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; -using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; using osu.Game.Utils; +using osuTK.Graphics; -namespace osu.Game.Screens.Select.Details +namespace osu.Game.Overlays.BeatmapSet { public partial class AdvancedStats : Container { diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index f2630caa831e..59b0547d4ee8 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -9,10 +9,12 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Extensions; using osu.Game.Graphics; @@ -32,7 +34,7 @@ public partial class BeatmapPicker : Container private const float tile_spacing = 2; private readonly LinkFlowContainer infoContainer; - private readonly Statistic plays, favourites; + private readonly Statistic nominations, plays, favourites; public readonly DifficultiesContainer Difficulties; @@ -107,7 +109,14 @@ public BeatmapPicker() Margin = new MarginPadding { Top = 5 }, Children = new[] { - plays = new Statistic(FontAwesome.Solid.PlayCircle), + nominations = new Statistic(FontAwesome.Solid.ThumbsUp) + { + TooltipText = BeatmapsetsStrings.ShowStatsNominations, + }, + plays = new Statistic(FontAwesome.Solid.PlayCircle) + { + TooltipText = BeatmapsetsStrings.ShowStatsPlaycount, + }, favourites = new Statistic(FontAwesome.Solid.Heart), }, }, @@ -176,8 +185,17 @@ private void updateDisplay() // Else just choose the first available difficulty for now. Beatmap.Value ??= Difficulties.FirstOrDefault()?.Beatmap; + if (beatmapSet?.Status == BeatmapOnlineStatus.Pending && beatmapSet.NominationStatus != null) + { + nominations.Show(); + nominations.Value = beatmapSet.NominationStatus.Current; + } + else + nominations.Hide(); + plays.Value = BeatmapSet?.PlayCount ?? 0; favourites.Value = BeatmapSet?.FavouriteCount ?? 0; + favourites.TooltipText = BeatmapSet?.FavouriteCount > 0 ? BeatmapsetsStrings.ShowStatsFavourites : BeatmapsetsStrings.ShowStatsNoFavourites; updateDifficultyButtons(); } @@ -367,7 +385,7 @@ private void load(OverlayColourProvider colourProvider) } } - private partial class Statistic : FillFlowContainer + private partial class Statistic : FillFlowContainer, IHasTooltip { private readonly OsuSpriteText text; @@ -407,6 +425,8 @@ public Statistic(IconUsage icon) }, }; } + + public LocalisableString TooltipText { get; set; } } public enum DifficultySelectorState diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index f75e7b1d3c95..3c5f15addeed 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -242,12 +242,14 @@ private void load(OverlayColourProvider colourProvider) BeatmapSet.BindValueChanged(setInfo => { - Picker.BeatmapSet = rulesetSelector.BeatmapSet = author.BeatmapSet = beatmapAvailability.BeatmapSet = Details.BeatmapSet = setInfo.NewValue; - cover.OnlineInfo = setInfo.NewValue; + var newBeatmapSet = setInfo.NewValue; + + Picker.BeatmapSet = rulesetSelector.BeatmapSet = author.BeatmapSet = beatmapAvailability.BeatmapSet = Details.BeatmapSet = newBeatmapSet; + cover.OnlineInfo = newBeatmapSet; downloadTracker?.RemoveAndDisposeImmediately(); - if (setInfo.NewValue == null) + if (newBeatmapSet == null) { onlineStatusPill.FadeTo(0.5f, 500, Easing.OutQuint); videoIconPill.Hide(); @@ -261,7 +263,10 @@ private void load(OverlayColourProvider colourProvider) } else { - downloadTracker = new BeatmapDownloadTracker(setInfo.NewValue); + foreach (var beatmap in newBeatmapSet.Beatmaps) + beatmap.BeatmapSet = newBeatmapSet; + + downloadTracker = new BeatmapDownloadTracker(newBeatmapSet); downloadTracker.State.BindValueChanged(_ => updateDownloadButtons()); AddInternal(downloadTracker); @@ -269,18 +274,18 @@ private void load(OverlayColourProvider colourProvider) loading.Hide(); - if (setInfo.NewValue.HasVideo) + if (newBeatmapSet.HasVideo) videoIconPill.Show(); else videoIconPill.Hide(); - if (setInfo.NewValue.HasStoryboard) + if (newBeatmapSet.HasStoryboard) storyboardIconPill.Show(); else storyboardIconPill.Hide(); - var titleText = new RomanisableString(setInfo.NewValue.TitleUnicode, setInfo.NewValue.Title); - var artistText = new RomanisableString(setInfo.NewValue.ArtistUnicode, setInfo.NewValue.Artist); + var titleText = new RomanisableString(newBeatmapSet.TitleUnicode, newBeatmapSet.Title); + var artistText = new RomanisableString(newBeatmapSet.ArtistUnicode, newBeatmapSet.Artist); title.Clear(); artist.Clear(); @@ -290,13 +295,13 @@ private void load(OverlayColourProvider colourProvider) title.AddArbitraryDrawable(Empty().With(d => d.Width = 5)); title.AddArbitraryDrawable(externalLink = new ExternalLinkButton()); - if (setInfo.NewValue.HasExplicitContent) + if (newBeatmapSet.HasExplicitContent) { title.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); title.AddArbitraryDrawable(new ExplicitContentBeatmapBadge()); } - if (setInfo.NewValue.FeaturedInSpotlight) + if (newBeatmapSet.FeaturedInSpotlight) { title.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); title.AddArbitraryDrawable(new SpotlightBeatmapBadge()); @@ -304,7 +309,7 @@ private void load(OverlayColourProvider colourProvider) artist.AddLink(artistText, LinkAction.SearchBeatmapSet, LocalisableString.Interpolate($@"artist=""""{artistText}""""")); - if (setInfo.NewValue.TrackId != null) + if (newBeatmapSet.TrackId != null) { artist.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); artist.AddArbitraryDrawable(new FeaturedArtistBeatmapBadge()); diff --git a/osu.Game/Overlays/BeatmapSet/Details.cs b/osu.Game/Overlays/BeatmapSet/Details.cs index 7d69cb73293e..50c5d5e4c515 100644 --- a/osu.Game/Overlays/BeatmapSet/Details.cs +++ b/osu.Game/Overlays/BeatmapSet/Details.cs @@ -11,7 +11,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet.Buttons; using osu.Game.Rulesets; -using osu.Game.Screens.Select.Details; using osuTK; namespace osu.Game.Overlays.BeatmapSet diff --git a/osu.Game/Screens/Select/Details/FailRetryGraph.cs b/osu.Game/Overlays/BeatmapSet/FailRetryGraph.cs similarity index 98% rename from osu.Game/Screens/Select/Details/FailRetryGraph.cs rename to osu.Game/Overlays/BeatmapSet/FailRetryGraph.cs index 9891ef6463dc..697c879a35f5 100644 --- a/osu.Game/Screens/Select/Details/FailRetryGraph.cs +++ b/osu.Game/Overlays/BeatmapSet/FailRetryGraph.cs @@ -4,15 +4,15 @@ #nullable disable using System; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; -using System.Linq; -using osu.Game.Beatmaps; -namespace osu.Game.Screens.Select.Details +namespace osu.Game.Overlays.BeatmapSet { public partial class FailRetryGraph : Container { diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 96e6622507dd..e25c1370c548 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -2,7 +2,6 @@ // 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; @@ -130,21 +129,7 @@ public Info() private void updateUserTags() { - if (Beatmap.Value?.TopTags == null || Beatmap.Value.TopTags.Length == 0 || BeatmapSet.Value?.RelatedTags == null) - { - userTags.Metadata = null; - return; - } - - var tagsById = BeatmapSet.Value.RelatedTags.ToDictionary(t => t.Id); - userTags.Metadata = Beatmap.Value.TopTags - .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) - .Where(t => t.relatedTag != null) - // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria - .OrderByDescending(t => t.topTag.VoteCount) - .ThenBy(t => t.relatedTag!.Name) - .Select(t => t.relatedTag!.Name) - .ToArray(); + userTags.Metadata = Beatmap.Value?.GetTopUserTags().Select(t => t.Tag.Name).ToArray(); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs index 12fbc4c790c9..55dfa52143a9 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs @@ -1,13 +1,16 @@ // 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.Screens.Select.Leaderboards; +using System; using osu.Game.Graphics.UserInterface; using osu.Framework.Allocation; using osuTK.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Overlays.BeatmapSet { @@ -39,6 +42,27 @@ public ScopeSelectorTabItem(BeatmapLeaderboardScope value) { } + protected override LocalisableString CreateText() + { + switch (Value) + { + case BeatmapLeaderboardScope.Global: + return BeatmapsetsStrings.ShowScoreboardGlobal; + + case BeatmapLeaderboardScope.Country: + return BeatmapsetsStrings.ShowScoreboardCountry; + + case BeatmapLeaderboardScope.Friend: + return BeatmapsetsStrings.ShowScoreboardFriend; + + case BeatmapLeaderboardScope.Team: + return BeatmapsetsStrings.ShowScoreboardTeam; + + default: + throw new ArgumentOutOfRangeException(); + } + } + protected override bool OnHover(HoverEvent e) { Text.FadeColour(AccentColour); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs index b161ee49c63b..a3e406ac8d43 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs @@ -3,10 +3,10 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Screens.Select.Leaderboards; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Overlays.BeatmapSet.Scores { diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 0c8943ba7d7e..1e62b23780b7 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -106,7 +106,7 @@ private TableColumn[] createHeaders(IReadOnlyList scores) var ruleset = scores.First().Ruleset.CreateInstance(); - foreach (var resultGroup in ruleset.GetHitResults().GroupBy(r => r.displayName)) + foreach (var resultGroup in ruleset.GetHitResultsForDisplay().GroupBy(r => r.displayName)) { if (!resultGroup.Any(r => allScoreStatistics.Contains(r.result))) continue; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index cc0638327409..63729319dba1 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -19,7 +19,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osuTK; using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; diff --git a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs index 48732ac586a1..28190580da76 100644 --- a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs +++ b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs @@ -14,7 +14,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; -using osu.Game.Screens.Select.Details; namespace osu.Game.Overlays.BeatmapSet { diff --git a/osu.Game/Screens/Select/Details/UserRatings.cs b/osu.Game/Overlays/BeatmapSet/UserRatings.cs similarity index 99% rename from osu.Game/Screens/Select/Details/UserRatings.cs rename to osu.Game/Overlays/BeatmapSet/UserRatings.cs index 3664a893942e..3f65c055f8ef 100644 --- a/osu.Game/Screens/Select/Details/UserRatings.cs +++ b/osu.Game/Overlays/BeatmapSet/UserRatings.cs @@ -13,7 +13,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Resources.Localisation.Web; -namespace osu.Game.Screens.Select.Details +namespace osu.Game.Overlays.BeatmapSet { public partial class UserRatings : Container { diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index ad327f4b2825..05bafae6a103 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -79,25 +79,6 @@ protected override void LoadComplete() highlightedMessage.BindValueChanged(_ => processMessageHighlighting(), true); } - protected override void Update() - { - base.Update(); - - long? lastMinutes = null; - - for (int i = 0; i < ChatLineFlow.Count; i++) - { - if (ChatLineFlow[i] is ChatLine chatline) - { - long minutes = chatline.Message.Timestamp.ToUnixTimeSeconds() / 60; - - chatline.AlternatingBackground = i % 2 == 0; - chatline.RequiresTimestamp = minutes != lastMinutes; - lastMinutes = minutes; - } - } - } - /// /// Processes any pending message in . /// @@ -145,19 +126,28 @@ private void newMessagesArrived(IEnumerable newMessages) => Schedule(() // Add up to last Channel.MAX_HISTORY messages var displayMessages = newMessages.Skip(Math.Max(0, newMessages.Count() - Channel.MAX_HISTORY)); - Message lastMessage = chatLines.LastOrDefault()?.Message; + ChatLine lastLine = chatLines.LastOrDefault(); + Message lastMessage = lastLine?.Message; foreach (var message in displayMessages) { addDaySeparatorIfRequired(lastMessage, message); - var chatLine = CreateChatLine(message); + ChatLine line = CreateChatLine(message); - if (chatLine != null) - { - ChatLineFlow.Add(chatLine); - lastMessage = message; - } + if (line == null) + continue; + + long minutes = line.Message.Timestamp.ToUnixTimeSeconds() / 60; + long? lastMinutes = lastLine?.Message.Timestamp.ToUnixTimeSeconds() / 60; + + line.AlternatingBackground = lastLine?.AlternatingBackground == false; + line.RequiresTimestamp = minutes != lastMinutes; + + ChatLineFlow.Add(line); + + lastMessage = message; + lastLine = line; } var staleMessages = chatLines.Where(c => c.LifetimeEnd == double.MaxValue).ToArray(); @@ -232,7 +222,41 @@ private void expireAndAdjustScroll(Drawable d) private void messageRemoved(Message removed) => Schedule(() => { - chatLines.FirstOrDefault(c => c.Message == removed)?.FadeColour(Color4.Red, 400).FadeOut(600).Expire(); + const double fade_time = 600; + + ChatLine removedLine = chatLines.FirstOrDefault(c => c.Message == removed); + + if (removedLine == null) + return; + + removedLine.FadeColour(Color4.Red, 400).FadeOut(fade_time).Expire(); + + // Resolve new colours and timestamps resulting from the removal. + this.Delay(fade_time).Schedule(() => + { + ChatLine lastLine = null; + + // Preserve the colours of most-recent messages while updating the ones upwards in the list. + foreach (var line in chatLines.Reverse().Except([removedLine])) + { + if (lastLine != null) + line.AlternatingBackground = !lastLine.AlternatingBackground; + + lastLine = line; + } + + lastLine = null; + + // Timestamps may migrate to more recent messages. + foreach (var line in chatLines.Except([removedLine])) + { + long minutes = line.Message.Timestamp.ToUnixTimeSeconds() / 60; + long? lastMinutes = lastLine?.Message.Timestamp.ToUnixTimeSeconds() / 60; + line.RequiresTimestamp = minutes != lastMinutes; + + lastLine = line; + } + }); }); private IEnumerable chatLines => ChatLineFlow.Children.OfType(); diff --git a/osu.Game/Overlays/Chat/ReportChatPopover.cs b/osu.Game/Overlays/Chat/ReportChatPopover.cs index 265a17c7992f..42aa92f5c2da 100644 --- a/osu.Game/Overlays/Chat/ReportChatPopover.cs +++ b/osu.Game/Overlays/Chat/ReportChatPopover.cs @@ -33,7 +33,27 @@ private void report(ChatReportReason reason, string comments) { var request = new ChatReportRequest(message.Id, reason, comments); - request.Success += () => channelManager.CurrentChannel.Value.AddNewMessages(new InfoMessage(UsersStrings.ReportThanks.ToString())); + request.Success += () => + { + string thanksMessage; + + switch (channelManager.CurrentChannel.Value.Type) + { + case ChannelType.PM: + thanksMessage = """ + Chat moderators have been alerted. You have reported a private message so they will not be able to read history to maintain your privacy. Please make sure to include as much details as you can. + You can submit a second report with more details if required, or contact abuse@ppy.sh if a user is being extremely offensive. + You can also block a user via the block button on their user profile, or by right-clicking on their name in the chat and selecting "Block". + """; + break; + + default: + thanksMessage = @"Chat moderators have been alerted. Thanks for your help."; + break; + } + + channelManager.CurrentChannel.Value.AddNewMessages(new InfoMessage(thanksMessage)); + }; api.Queue(request); } diff --git a/osu.Game/Overlays/Comments/CommentEditor.cs b/osu.Game/Overlays/Comments/CommentEditor.cs index c4565923837b..d679b8cf4f2f 100644 --- a/osu.Game/Overlays/Comments/CommentEditor.cs +++ b/osu.Game/Overlays/Comments/CommentEditor.cs @@ -234,7 +234,7 @@ private partial class EditorTextBox : OsuTextBox public EditorTextBox() { - Masking = false; + Masking = DrawBorder = false; TextContainer.Height = 0.4f; } diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index 5e277357a949..20b12806ceb5 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -188,7 +188,7 @@ private void load(OverlayColourProvider colourProvider) protected override void LoadComplete() { User.BindValueChanged(_ => refetchComments()); - User.BindValueChanged(e => avatar.User = e.NewValue); + User.BindValueChanged(e => avatar.User = e.NewValue, true); Sort.BindValueChanged(_ => refetchComments(), true); base.LoadComplete(); } diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 805d99799805..c439905245d7 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -20,14 +20,13 @@ using System.Diagnostics; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Logging; -using osu.Framework.Platform; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Comments.Buttons; using osu.Game.Overlays.Dialog; -using osu.Game.Overlays.OSD; -using osu.Game.Resources.Localisation.Web; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Overlays.Comments { @@ -83,10 +82,7 @@ public partial class DrawableComment : CompositeDrawable private IAPIProvider api { get; set; } = null!; [Resolved] - private Clipboard clipboard { get; set; } = null!; - - [Resolved] - private OnScreenDisplay? onScreenDisplay { get; set; } + private OsuGame? game { get; set; } public DrawableComment(Comment comment, IReadOnlyList meta) { @@ -329,13 +325,13 @@ private void load(OverlayColourProvider colourProvider, DrawableComment? parentC if (WasDeleted) makeDeleted(); - actionsContainer.AddLink(CommonStrings.ButtonsPermalink, copyUrl); + actionsContainer.AddLink(WebCommonStrings.ButtonsPermalink, () => game?.CopyToClipboard($@"{api.Endpoints.APIUrl}/comments/{Comment.Id}")); actionsContainer.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); - actionsContainer.AddLink(CommonStrings.ButtonsReply.ToLower(), toggleReply); + actionsContainer.AddLink(WebCommonStrings.ButtonsReply.ToLower(), toggleReply); actionsContainer.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); if (Comment.UserId.HasValue && Comment.UserId.Value == api.LocalUser.Value.Id) - actionsContainer.AddLink(CommonStrings.ButtonsDelete.ToLower(), deleteComment); + actionsContainer.AddLink(WebCommonStrings.ButtonsDelete.ToLower(), deleteComment); else actionsContainer.AddArbitraryDrawable(new CommentReportButton(Comment)); @@ -389,7 +385,7 @@ private void deleteComment() if (dialogOverlay == null) deleteCommentRequest(); else - dialogOverlay.Push(new ConfirmDialog("Do you really want to delete your comment?", deleteCommentRequest)); + dialogOverlay.Push(new ConfirmDialog(DialogStrings.DeleteCommentBodyText, deleteCommentRequest)); } /// @@ -417,12 +413,6 @@ private void deleteCommentRequest() api.Queue(request); } - private void copyUrl() - { - clipboard.SetText($@"{api.Endpoints.APIUrl}/comments/{Comment.Id}"); - onScreenDisplay?.Display(new CopiedToClipboardToast()); - } - private void toggleReply() { if (replyEditorContainer.Count == 0) diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnline/CurrentlyOnlineDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnline/CurrentlyOnlineDisplay.cs new file mode 100644 index 000000000000..4dbe775d9b6e --- /dev/null +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnline/CurrentlyOnlineDisplay.cs @@ -0,0 +1,158 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Dashboard.CurrentlyOnline +{ + public partial class CurrentlyOnlineDisplay : CompositeDrawable + { + private Box background = null!; + private UserListToolbar userListToolbar = null!; + private Container listContainer = null!; + private LoadingLayer loading = null!; + private BasicSearchTextBox searchTextBox = null!; + + private CancellationTokenSource? listLoadCancellation; + + public CurrentlyOnlineDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Margin = new MarginPadding { Bottom = 20 }, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 40, + Vertical = 20 + }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 50), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + searchTextBox = new BasicSearchTextBox + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 40, + ReleaseFocusOnCommit = false, + HoldFocus = true, + PlaceholderText = HomeStrings.SearchPlaceholder, + }, + Empty(), + userListToolbar = new UserListToolbar(false) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + listContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING } + }, + loading = new LoadingLayer(true) + } + } + } + } + }; + + background.Colour = colourProvider.Background4; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + userListToolbar.DisplayStyle.BindValueChanged(_ => reloadList(), true); + } + + private void reloadList() + { + listLoadCancellation?.Cancel(); + var cancellationSource = listLoadCancellation = new CancellationTokenSource(); + + RealtimeUserList? currentList = listContainer.SingleOrDefault(); + RealtimeUserList newList = new RealtimeUserList(userListToolbar.DisplayStyle.Value) + { + SortCriteria = { BindTarget = userListToolbar.SortCriteria }, + SearchText = { BindTarget = searchTextBox.Current } + }; + + loading.Show(); + LoadComponentAsync(newList, finishLoad, cancellationSource.Token); + + void finishLoad(RealtimeUserList list) + { + loading.Hide(); + + if (currentList != null) + { + currentList.FadeOut(250, Easing.OutQuint).Expire(); + currentList.Delay(25).Schedule(() => currentList.BypassAutoSizeAxes = Axes.Y); + } + + listContainer.Add(newList); + newList.FadeInFromZero(250, Easing.OutQuint); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + listLoadCancellation?.Cancel(); + listLoadCancellation?.Dispose(); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnline/OnlineUserGridPanel.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnline/OnlineUserGridPanel.cs new file mode 100644 index 000000000000..3bc173990aba --- /dev/null +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnline/OnlineUserGridPanel.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.CurrentlyOnline +{ + internal partial class OnlineUserGridPanel : OnlineUserPanel + { + public OnlineUserGridPanel(APIUser user) + : base(user) + { + Size = new Vector2(290, 162); + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new DelayedLoadUnloadWrapper(() => + { + var content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Children = new Drawable[] + { + new UserGridPanel(User) + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }, + new PurpleRoundedButton + { + RelativeSizeAxes = Axes.X, + Text = "Spectate", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = BeginSpectating, + Enabled = { BindTarget = CanSpectate }, + } + } + }; + + content.OnLoadComplete += _ => content.FadeInFromZero(800, Easing.OutQuint); + + return content; + }, 40, 5000) + { + RelativeSizeAxes = Axes.Both, + }; + } + } +} diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnline/OnlineUserListPanel.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnline/OnlineUserListPanel.cs new file mode 100644 index 000000000000..8c76f2fbc31d --- /dev/null +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnline/OnlineUserListPanel.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Users; + +namespace osu.Game.Overlays.Dashboard.CurrentlyOnline +{ + public partial class OnlineUserListPanel : OnlineUserPanel + { + public OnlineUserListPanel(APIUser user) + : base(user) + { + RelativeSizeAxes = Axes.X; + Height = 40; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new DelayedLoadUnloadWrapper(() => + { + var content = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new UserListPanel(User), + new PurpleRoundedButton + { + Width = 100, + Text = "Spectate", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = BeginSpectating, + Enabled = { BindTarget = CanSpectate }, + } + } + } + }; + + content.OnLoadComplete += _ => content.FadeInFromZero(800, Easing.OutQuint); + + return content; + }, 40, 5000) + { + RelativeSizeAxes = Axes.Both, + }; + } + } +} diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnline/OnlineUserPanel.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnline/OnlineUserPanel.cs new file mode 100644 index 000000000000..50278fc60df0 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnline/OnlineUserPanel.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Framework.Screens; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens; +using osu.Game.Screens.Play; + +namespace osu.Game.Overlays.Dashboard.CurrentlyOnline +{ + public abstract partial class OnlineUserPanel : CompositeDrawable, IFilterable + { + public readonly APIUser User; + + public readonly Bindable CanSpectate = new Bindable(); + + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + + protected OnlineUserPanel(APIUser user) + { + User = user; + FilterTerms = new LocalisableString[] { User.Username }; + } + + protected void BeginSpectating() + { + performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))); + } + + public IEnumerable FilterTerms { get; } + + public bool FilteringActive { set; get; } + + public bool MatchingFilter + { + set + { + if (value) + Show(); + else + Hide(); + } + } + } +} diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnline/RealtimeUserList.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnline/RealtimeUserList.cs new file mode 100644 index 000000000000..32f89dde4bc2 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnline/RealtimeUserList.cs @@ -0,0 +1,264 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; +using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Rulesets; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.CurrentlyOnline +{ + internal partial class RealtimeUserList : CompositeDrawable + { + public readonly IBindable SortCriteria = new Bindable(); + public readonly IBindable SearchText = new Bindable(); + + private readonly IBindableDictionary onlineUserPresences = new BindableDictionary(); + private readonly Dictionary userPanels = new Dictionary(); + private readonly OverlayPanelDisplayStyle style; + + private OnlineUserSearchContainer searchContainer = null!; + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + [Cached(typeof(UserLookupCache))] // not used at the moment. + private UserWithRankLookupCache userCache { get; set; } = new UserWithRankLookupCache(); + + public RealtimeUserList(OverlayPanelDisplayStyle style) + { + this.style = style; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(userCache); + + InternalChild = searchContainer = new OnlineUserSearchContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 3), + SortCriteria = { BindTarget = SortCriteria }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + onlineUserPresences.BindTo(metadataClient.UserPresences); + onlineUserPresences.BindCollectionChanged(onUserPresenceUpdated, true); + + SearchText.BindValueChanged(onSearchTextChanged, true); + + Scheduler.AddDelayed(updateUsers, 2000, true); + } + + private void onSearchTextChanged(ValueChangedEvent search) + { + searchContainer.SearchTerm = search.NewValue; + } + + private void onUserPresenceUpdated(object? sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Replace: + foreach ((int userId, var presence) in e.NewItems!) + { + if (userPanels.TryGetValue(userId, out var userPanel)) + updateUserSpectateState(presence, userPanel); + } + + break; + + case NotifyDictionaryChangedAction.Add: + pendingUsers.AddRange(e.NewItems!.Select(i => i.Key)); + + break; + + case NotifyDictionaryChangedAction.Remove: + foreach ((int userId, _) in e.OldItems!) + { + if (userPanels.Remove(userId, out var userPanel)) + userPanel.Expire(); + + pendingUsers.Remove(userId); + } + + break; + } + } + + private readonly HashSet pendingUsers = new HashSet(); + + protected override void Update() + { + base.Update(); + + // ReSharper disable once InconsistentlySynchronizedField + if (pendingUsers.Count > 100) + updateUsers(); + } + + private void updateUsers() + { + if (pendingUsers.Count == 0) + return; + + // partitioning here is just to break up the requests. + // without this, the intitial request will take seconds to minutes. + const int partition_size = 50; + + for (int i = 0; i <= pendingUsers.Count / partition_size; i++) + { + int[] partitionedUsers = pendingUsers.Skip(i * partition_size).Take(partition_size).ToArray(); + + userCache.GetUsersAsync(partitionedUsers).ContinueWith(task => Schedule(() => + { + var users = task.GetResultSafely(); + + foreach (APIUser? user in users) + { + if (user == null) + continue; + + var presence = metadataClient.GetPresence(user.Id); + + if (presence == null) + continue; + + if (userPanels.TryGetValue(user.Id, out _)) + return; + + // This is quite dodgy – it affects the global `UserLookupCache`. + // + // but it's the best we can do for now. + // this should probably be returned by server-spectator not osu-web. + var now = DateTimeOffset.Now; + + // Drop the seconds to avoid every new user appearing at the top of the list and causing + // the list to visually churn in an unusable way (especially on first display). + user.LastVisit = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, now.Offset); + + var panel = createUserPanel(user); + updateUserSpectateState(presence.Value, panel); + searchContainer.Add(userPanels[user.Id] = panel); + } + })); + } + + pendingUsers.Clear(); + } + + private static void updateUserSpectateState(UserPresence presence, OnlineUserPanel userPanel) + { + switch (presence.Activity) + { + default: + userPanel.CanSpectate.Value = false; + break; + + case UserActivity.InSoloGame: + case UserActivity.InMultiplayerGame: + case UserActivity.InPlaylistGame: + userPanel.CanSpectate.Value = true; + break; + } + } + + private OnlineUserPanel createUserPanel(APIUser user) + { + switch (style) + { + default: + case OverlayPanelDisplayStyle.Card: + return new OnlineUserGridPanel(user) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }; + + case OverlayPanelDisplayStyle.List: + return new OnlineUserListPanel(user); + } + } + + private partial class OnlineUserSearchContainer : SearchContainer + { + public readonly IBindable SortCriteria = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + SortCriteria.BindValueChanged(_ => InvalidateLayout(), true); + } + + public override IEnumerable FlowingChildren + { + get + { + IEnumerable panels = base.FlowingChildren.OfType(); + + switch (SortCriteria.Value) + { + default: + case UserSortCriteria.LastVisit: + // Todo: Last visit time is not currently updated according to realtime user presence. + return panels.OrderByDescending(panel => panel.User.LastVisit) + .ThenBy(panel => panel.User.Rank?.Rank != null) + .ThenBy(panel => panel.User.Rank?.Rank ?? 0); + + case UserSortCriteria.Rank: + // Todo: Rank is not currently displayed in the panels. Additionally the sort mode kind of breaks if you change ruleset with this overlay open. + return panels.OrderByDescending(panel => panel.User.Rank?.Rank != null).ThenBy(panel => panel.User.Rank?.Rank ?? 0); + + case UserSortCriteria.Username: + return panels.OrderBy(panel => panel.User.Username); + } + } + } + } + + /// + /// This is implemented local to avoid invalidating the full cache on ruleset change at a global `UserLookupCache` level. + /// We should probably do better than this (server-spectator sending the rank data instead? something else?). + /// + private partial class UserWithRankLookupCache : UserLookupCache + { + [Resolved] + private IBindable ruleset { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(ruleset => + { + if (ruleset.OldValue?.OnlineID != ruleset.NewValue?.OnlineID) + Clear(); + }); + } + + protected override LookupUsersRequest CreateRequest(IEnumerable ids) => new LookupUsersRequest(ids.ToArray(), ruleset.Value?.OnlineID >= 0 ? ruleset.Value.OnlineID : null); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs deleted file mode 100644 index 02fe681492e4..000000000000 --- a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs +++ /dev/null @@ -1,239 +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.Diagnostics; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Framework.Screens; -using osu.Game.Database; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Metadata; -using osu.Game.Resources.Localisation.Web; -using osu.Game.Screens; -using osu.Game.Screens.OnlinePlay.Match.Components; -using osu.Game.Screens.Play; -using osu.Game.Users; -using osuTK; - -namespace osu.Game.Overlays.Dashboard -{ - internal partial class CurrentlyOnlineDisplay : CompositeDrawable - { - private const float search_textbox_height = 40; - private const float padding = 10; - - private readonly IBindableDictionary onlineUserPresences = new BindableDictionary(); - private readonly Dictionary userPanels = new Dictionary(); - - private SearchContainer userFlow = null!; - private BasicSearchTextBox searchTextBox = null!; - - [Resolved] - private MetadataClient metadataClient { get; set; } = null!; - - [Resolved] - private UserLookupCache users { get; set; } = null!; - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.X, - Height = padding * 2 + search_textbox_height, - Colour = colourProvider.Background4, - }, - new Container - { - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = padding }, - Child = searchTextBox = new BasicSearchTextBox - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Height = search_textbox_height, - ReleaseFocusOnCommit = false, - HoldFocus = true, - PlaceholderText = HomeStrings.SearchPlaceholder, - }, - }, - userFlow = new SearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), - Padding = new MarginPadding - { - Top = padding * 3 + search_textbox_height, - Bottom = padding, - Right = padding, - Left = padding, - }, - }, - }; - - searchTextBox.Current.ValueChanged += text => userFlow.SearchTerm = text.NewValue; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - onlineUserPresences.BindTo(metadataClient.UserPresences); - onlineUserPresences.BindCollectionChanged(onUserPresenceUpdated, true); - } - - protected override void OnFocus(FocusEvent e) - { - base.OnFocus(e); - - searchTextBox.TakeFocus(); - } - - private void onUserPresenceUpdated(object? sender, NotifyDictionaryChangedEventArgs e) => Schedule(() => - { - switch (e.Action) - { - case NotifyDictionaryChangedAction.Add: - Debug.Assert(e.NewItems != null); - - foreach (var kvp in e.NewItems) - { - int userId = kvp.Key; - - users.GetUserAsync(userId).ContinueWith(task => - { - if (task.GetResultSafely() is APIUser user) - Schedule(() => userFlow.Add(userPanels[userId] = createUserPanel(user))); - }); - } - - break; - - case NotifyDictionaryChangedAction.Remove: - Debug.Assert(e.OldItems != null); - - foreach (var kvp in e.OldItems) - { - int userId = kvp.Key; - if (userPanels.Remove(userId, out var userPanel)) - userPanel.Expire(); - } - - break; - } - }); - - private OnlineUserPanel createUserPanel(APIUser user) => - new OnlineUserPanel(user).With(panel => - { - panel.Anchor = Anchor.TopCentre; - panel.Origin = Anchor.TopCentre; - }); - - public partial class OnlineUserPanel : CompositeDrawable, IFilterable - { - public readonly APIUser User; - - private PurpleRoundedButton spectateButton = null!; - - public IEnumerable FilterTerms { get; } - - [Resolved] - private IPerformFromScreenRunner? performer { get; set; } - - [Resolved] - private MetadataClient? metadataClient { get; set; } - - public bool FilteringActive { set; get; } - - public bool MatchingFilter - { - set - { - if (value) - Show(); - else - Hide(); - } - } - - public OnlineUserPanel(APIUser user) - { - User = user; - - FilterTerms = new LocalisableString[] { User.Username }; - - AutoSizeAxes = Axes.Both; - } - - protected override void Update() - { - base.Update(); - - // TODO: we probably don't want to do this every frame. - var activity = metadataClient?.GetPresence(User.Id)?.Activity; - - switch (activity) - { - default: - spectateButton.Enabled.Value = false; - break; - - case UserActivity.InSoloGame: - case UserActivity.InMultiplayerGame: - case UserActivity.InPlaylistGame: - case UserActivity.PlayingDailyChallenge: - spectateButton.Enabled.Value = true; - break; - } - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(2), - Width = 290, - Children = new Drawable[] - { - new UserGridPanel(User) - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - }, - spectateButton = new PurpleRoundedButton - { - RelativeSizeAxes = Axes.X, - Text = "Spectate", - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Action = () => performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))), - } - } - }, - }; - } - } - } -} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index 56cf9fc6690e..66e90e51eba1 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -124,7 +124,7 @@ private void load(OverlayColourProvider colourProvider) PlaceholderText = HomeStrings.SearchPlaceholder, }, Empty(), - userListToolbar = new UserListToolbar + userListToolbar = new UserListToolbar(true) { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -173,10 +173,12 @@ private void reloadList() listLoadCancellation?.Cancel(); var cancellationSource = listLoadCancellation = new CancellationTokenSource(); - FriendsList? currentList = listContainer.SingleOrDefault(); + // There may be more than one active list in the container due to the delayed fade out. + FriendsList? currentList = listContainer.SingleOrDefault(d => d.LifetimeEnd == double.MaxValue); + FriendsList newList = new FriendsList(userListToolbar.DisplayStyle.Value, apiFriends.Select(f => f.TargetUser!).ToArray()) { - OnlineStream = { BindTarget = streamControl.Current }, + StatusFilter = { BindTarget = streamControl.Current }, SortCriteria = { BindTarget = userListToolbar.SortCriteria }, SearchText = { BindTarget = searchTextBox.Current } }; diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs b/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs index c7689dff8f23..8d95222ee2d7 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Dashboard.Friends { public partial class FriendsList : CompositeDrawable { - public readonly IBindable OnlineStream = new Bindable(); + public readonly IBindable StatusFilter = new Bindable(); public readonly IBindable SortCriteria = new Bindable(); public readonly IBindable SearchText = new Bindable(); @@ -61,7 +61,7 @@ protected override void LoadComplete() friendPresences.BindCollectionChanged(onFriendPresencesChanged); SearchText.BindValueChanged(onSearchTextChanged, true); - OnlineStream.BindValueChanged(onFriendsStreamChanged, true); + StatusFilter.BindValueChanged(onStatusFilterChanged, true); } private void onFriendPresencesChanged(object? sender, NotifyDictionaryChangedEventArgs e) @@ -80,7 +80,7 @@ private void onSearchTextChanged(ValueChangedEvent search) searchContainer.SearchTerm = search.NewValue; } - private void onFriendsStreamChanged(ValueChangedEvent stream) + private void onStatusFilterChanged(ValueChangedEvent status) { updatePanelVisibilities(); } @@ -89,7 +89,7 @@ private void updatePanelVisibilities() { foreach (var panel in searchContainer) { - switch (OnlineStream.Value) + switch (StatusFilter.Value) { case OnlineStatus.All: panel.CanBeShown.Value = true; diff --git a/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs index 3f31ceee1a4a..62ba89495c88 100644 --- a/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs +++ b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osuTK; using osu.Framework.Bindables; +using osu.Game.Configuration; namespace osu.Game.Overlays.Dashboard.Friends { @@ -14,11 +16,16 @@ public partial class UserListToolbar : CompositeDrawable public Bindable DisplayStyle => styleControl.Current; + private readonly Bindable configDisplayStyle = new Bindable(); + + private readonly bool supportsBrickMode; private readonly UserSortTabControl sortControl; private readonly OverlayPanelDisplayStyleControl styleControl; - public UserListToolbar() + public UserListToolbar(bool supportsBrickMode) { + this.supportsBrickMode = supportsBrickMode; + AutoSizeAxes = Axes.Both; AddInternal(new FillFlowContainer @@ -33,7 +40,7 @@ public UserListToolbar() Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - styleControl = new OverlayPanelDisplayStyleControl + styleControl = new OverlayPanelDisplayStyleControl(supportsBrickMode) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -41,5 +48,30 @@ public UserListToolbar() } }); } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.DashboardSortMode, SortCriteria); + config.BindWith(OsuSetting.DashboardDisplayStyle, configDisplayStyle); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + configDisplayStyle.BindValueChanged(style => + { + if (style.NewValue == OverlayPanelDisplayStyle.Brick && !supportsBrickMode) + DisplayStyle.Value = OverlayPanelDisplayStyle.Card; + else + DisplayStyle.Value = style.NewValue; + }, true); + + DisplayStyle.BindValueChanged(style => + { + configDisplayStyle.Value = style.NewValue; + }, true); + } } } diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs index 1912736135d7..941c5dc68564 100644 --- a/osu.Game/Overlays/DashboardOverlay.cs +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Online.Metadata; using osu.Game.Overlays.Dashboard; +using osu.Game.Overlays.Dashboard.CurrentlyOnline; using osu.Game.Overlays.Dashboard.Friends; namespace osu.Game.Overlays diff --git a/osu.Game/Overlays/Dialog/ConfirmDialog.cs b/osu.Game/Overlays/Dialog/ConfirmDialog.cs index f1caac8b5d40..f6a5ca4f9ca1 100644 --- a/osu.Game/Overlays/Dialog/ConfirmDialog.cs +++ b/osu.Game/Overlays/Dialog/ConfirmDialog.cs @@ -6,7 +6,8 @@ using System; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Overlays.Dialog { @@ -24,7 +25,7 @@ public partial class ConfirmDialog : PopupDialog public ConfirmDialog(LocalisableString message, Action onConfirm, Action onCancel = null) { HeaderText = message; - BodyText = "Last chance to turn back"; + BodyText = DialogStrings.ConfirmDialogBodyText; Icon = FontAwesome.Solid.ExclamationTriangle; @@ -32,12 +33,12 @@ public ConfirmDialog(LocalisableString message, Action onConfirm, Action onCance { new PopupDialogOkButton { - Text = @"Yes", + Text = DialogStrings.Confirm, Action = onConfirm }, new PopupDialogCancelButton { - Text = CommonStrings.ButtonsCancel, + Text = WebCommonStrings.ButtonsCancel, Action = onCancel }, }; diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index edadc333c8bf..09bdb8b843b7 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -25,7 +25,7 @@ using osu.Game.Screens; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Tests.Visual; using osuTK; diff --git a/osu.Game/Overlays/HoldToConfirmOverlay.cs b/osu.Game/Overlays/HoldToConfirmOverlay.cs index 6cdbc6450db7..b9c32f9f8c47 100644 --- a/osu.Game/Overlays/HoldToConfirmOverlay.cs +++ b/osu.Game/Overlays/HoldToConfirmOverlay.cs @@ -61,10 +61,15 @@ private void load() audio.Samples.AddAdjustment(AdjustableProperty.Volume, audioVolume); } - protected override void Dispose(bool isDisposing) + protected void RemoveAudioAdjustments() { audio?.Tracks.RemoveAdjustment(AdjustableProperty.Volume, audioVolume); audio?.Samples.RemoveAdjustment(AdjustableProperty.Volume, audioVolume); + } + + protected override void Dispose(bool isDisposing) + { + RemoveAudioAdjustments(); base.Dispose(isDisposing); } } diff --git a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs index 2cdc4bf6a68f..38025de1d9bb 100644 --- a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs +++ b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -104,12 +105,12 @@ private void showEmailVerification() { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Text = "An email has been sent to you with a verification code. Enter the code.", + Text = LoginPanelStrings.CodeSent, }, codeTextBox = new OsuTextBox { InputProperties = new TextInputProperties(TextInputType.Code), - PlaceholderText = "Enter code", + PlaceholderText = LoginPanelStrings.EnterCode, RelativeSizeAxes = Axes.X, TabbableContentContainer = this, }, @@ -169,12 +170,12 @@ private void showTotpVerification() { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Text = "Please enter the code from your authenticator app.", + Text = UserVerificationStrings.BoxTotpHeading, }, codeTextBox = new OsuNumberBox { InputProperties = new TextInputProperties(TextInputType.NumericalPassword), - PlaceholderText = "Enter code", + PlaceholderText = LoginPanelStrings.EnterCode, RelativeSizeAxes = Axes.X, TabbableContentContainer = this, }, diff --git a/osu.Game/Overlays/Mods/AddPresetButton.cs b/osu.Game/Overlays/Mods/AddPresetButton.cs index e4f7f83c1102..0844291f91df 100644 --- a/osu.Game/Overlays/Mods/AddPresetButton.cs +++ b/osu.Game/Overlays/Mods/AddPresetButton.cs @@ -27,7 +27,6 @@ public partial class AddPresetButton : ShearedToggleButton, IHasPopover private Bindable> selectedMods { get; set; } = null!; public AddPresetButton() - : base(1) { RelativeSizeAxes = Axes.X; Height = ModSelectPanel.HEIGHT; diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs index 817a61f7ac60..831f03f78c57 100644 --- a/osu.Game/Overlays/Mods/AddPresetPopover.cs +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -74,11 +74,12 @@ public AddPresetPopover(AddPresetButton addPresetButton) Direction = FillDirection.Vertical, Children = new Drawable[] { - createButton = new ShearedButton(content_width) + createButton = new ShearedButton { // todo: for some very odd reason, this needs to be anchored to topright for the fill flow to be correctly sized to the AABB of the sheared button Anchor = Anchor.TopRight, Origin = Anchor.TopRight, + Width = content_width, Text = ModSelectOverlayStrings.AddPreset, Action = createPreset } diff --git a/osu.Game/Overlays/Mods/DeselectAllModsButton.cs b/osu.Game/Overlays/Mods/DeselectAllModsButton.cs index 0e60fc34147e..0e5bb4d3dad7 100644 --- a/osu.Game/Overlays/Mods/DeselectAllModsButton.cs +++ b/osu.Game/Overlays/Mods/DeselectAllModsButton.cs @@ -15,8 +15,9 @@ public partial class DeselectAllModsButton : ShearedButton private readonly Bindable> selectedMods = new Bindable>(); public DeselectAllModsButton(ModSelectOverlay modSelectOverlay) - : base(ModSelectOverlay.BUTTON_WIDTH) { + Width = ModSelectOverlay.BUTTON_WIDTH; + Text = CommonStrings.DeselectAll; Action = modSelectOverlay.DeselectAll; @@ -32,7 +33,7 @@ protected override void LoadComplete() private void updateEnabledState() { - Enabled.Value = selectedMods.Value.Any(); + Enabled.Value = selectedMods.Value.Any(m => m.Type != ModType.System); } } } diff --git a/osu.Game/Overlays/Mods/EditPresetPopover.cs b/osu.Game/Overlays/Mods/EditPresetPopover.cs index eb128c77921a..820ffad26551 100644 --- a/osu.Game/Overlays/Mods/EditPresetPopover.cs +++ b/osu.Game/Overlays/Mods/EditPresetPopover.cs @@ -114,22 +114,24 @@ private void load() Direction = FillDirection.Vertical, Children = new Drawable[] { - useCurrentModsButton = new ShearedButton(content_width) + useCurrentModsButton = new ShearedButton { // todo: for some very odd reason, this needs to be anchored to topright for the fill flow to be correctly sized to the AABB of the sheared button Anchor = Anchor.TopRight, Origin = Anchor.TopRight, + Width = content_width, Text = ModSelectOverlayStrings.UseCurrentMods, DarkerColour = colours.Blue1, LighterColour = colours.Blue0, TextColour = colourProvider.Background6, Action = useCurrentMods, }, - saveButton = new ShearedButton(content_width) + saveButton = new ShearedButton { // todo: for some very odd reason, this needs to be anchored to topright for the fill flow to be correctly sized to the AABB of the sheared button Anchor = Anchor.TopRight, Origin = Anchor.TopRight, + Width = content_width, Text = Resources.Localisation.Web.CommonStrings.ButtonsSave, DarkerColour = colours.Orange1, LighterColour = colours.Orange0, diff --git a/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs index bf58efc3399b..bc5914d41ef3 100644 --- a/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs +++ b/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs @@ -37,7 +37,7 @@ public ClassicModHotkeyHandler(bool allowIncompatibleSelection) this.allowIncompatibleSelection = allowIncompatibleSelection; } - public bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable availableMods) + public bool HandleModHotkeyPressed(KeyDownEvent e, IEnumerable availableMods) { if (!mod_type_lookup.TryGetValue(e.Key, out var typesToMatch)) return false; diff --git a/osu.Game/Overlays/Mods/Input/IModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/IModHotkeyHandler.cs index d2cc0e84d262..3dfda7609f47 100644 --- a/osu.Game/Overlays/Mods/Input/IModHotkeyHandler.cs +++ b/osu.Game/Overlays/Mods/Input/IModHotkeyHandler.cs @@ -17,6 +17,6 @@ public interface IModHotkeyHandler /// The event representing the user's keypress. /// The list of currently available mods. /// Whether the supplied event was handled as a mod selection/deselection. - bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable availableMods); + bool HandleModHotkeyPressed(KeyDownEvent e, IEnumerable availableMods); } } diff --git a/osu.Game/Overlays/Mods/Input/NoopModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/NoopModHotkeyHandler.cs index 3f7a6298a1aa..7115ef16ff16 100644 --- a/osu.Game/Overlays/Mods/Input/NoopModHotkeyHandler.cs +++ b/osu.Game/Overlays/Mods/Input/NoopModHotkeyHandler.cs @@ -12,6 +12,6 @@ namespace osu.Game.Overlays.Mods.Input /// public class NoopModHotkeyHandler : IModHotkeyHandler { - public bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable availableMods) => false; + public bool HandleModHotkeyPressed(KeyDownEvent e, IEnumerable availableMods) => false; } } diff --git a/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs index e6380634384c..42e65c884e39 100644 --- a/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs +++ b/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs @@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Mods.Input { /// /// This implementation of receives a sequence of s, - /// and maps the sequence of keys onto the items it is provided in . + /// and maps the sequence of keys onto the items it is provided in . /// In this case, particular mods are not bound to particular keys, the hotkeys are a byproduct of mod ordering. /// public class SequentialModHotkeyHandler : IModHotkeyHandler @@ -42,7 +42,7 @@ private SequentialModHotkeyHandler(Key[] keys) toggleKeys = keys; } - public bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable availableMods) + public bool HandleModHotkeyPressed(KeyDownEvent e, IEnumerable availableMods) { int index = Array.IndexOf(toggleKeys, e.Key); if (index < 0) diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index 7d2ce54074c4..d7ac8a9ff8e7 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -348,7 +348,7 @@ protected override bool OnKeyDown(KeyDownEvent e) if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.Repeat) return false; - return hotkeyHandler.HandleHotkeyPressed(e, availableMods); + return hotkeyHandler.HandleModHotkeyPressed(e, availableMods); } #endregion diff --git a/osu.Game/Overlays/Mods/ModPresetColumn.cs b/osu.Game/Overlays/Mods/ModPresetColumn.cs index 0803389f4562..68c802ec4fb3 100644 --- a/osu.Game/Overlays/Mods/ModPresetColumn.cs +++ b/osu.Game/Overlays/Mods/ModPresetColumn.cs @@ -2,18 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osuTK; +using osuTK.Input; using Realms; namespace osu.Game.Overlays.Mods @@ -28,6 +31,8 @@ public partial class ModPresetColumn : ModSelectColumn private const float contracted_width = WIDTH - 120; + private readonly Key[] toggleKeys = { Key.Number1, Key.Number2, Key.Number3, Key.Number4, Key.Number5, Key.Number6, Key.Number7, Key.Number8, Key.Number9, Key.Number0 }; + [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -79,10 +84,20 @@ private void asyncLoadPanels(IRealmCollection presets, ChangeSet? cha return; } - latestLoadTask = LoadComponentsAsync(presets.Select(p => new ModPresetPanel(p.ToLive(realm)) + var panels = new List(); + + for (int i = 0; i < presets.Count; i++) { - Shear = Vector2.Zero - }), loaded => + var preset = presets[i]; + + panels.Add(new ModPresetPanel(preset.ToLive(realm)) + { + Index = i < 10 ? (i + 1) % 10 : null, + Shear = Vector2.Zero + }); + } + + latestLoadTask = LoadComponentsAsync(panels, loaded => { removeAndDisposePresetPanels(); ItemsFlow.AddRange(loaded); @@ -95,6 +110,24 @@ void removeAndDisposePresetPanels() } } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.Repeat) + return false; + + int index = Array.IndexOf(toggleKeys, e.Key); + if (index < 0) + return false; + + var panel = ItemsFlow.OfType().ElementAtOrDefault(index); + if (panel == null) + return false; + + panel.Toggle(); + + return true; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Overlays/Mods/ModPresetPanel.cs b/osu.Game/Overlays/Mods/ModPresetPanel.cs index 568ca5ecc90a..4094e8750715 100644 --- a/osu.Game/Overlays/Mods/ModPresetPanel.cs +++ b/osu.Game/Overlays/Mods/ModPresetPanel.cs @@ -6,12 +6,14 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets.Mods; @@ -22,6 +24,8 @@ public partial class ModPresetPanel : ModSelectPanel, IHasCustomTooltip Preset; + public int? Index { get; init; } + public override BindableBool Active { get; } = new BindableBool(); [Resolved] @@ -32,18 +36,36 @@ public partial class ModPresetPanel : ModSelectPanel, IHasCustomTooltip preset) { Preset = preset; - Title = preset.Value.Name; Description = preset.Value.Description; } + protected override float IdleSwitchWidth => 24; + protected override float ExpandedSwitchWidth => 40; + [BackgroundDependencyLoader] private void load(OsuColour colours) { AccentColour = colours.Orange1; + + if (Index != null) + { + SwitchContainer.Child = shortcutKeyText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -OsuGame.SHEAR, + Text = Index.Value.ToString(), + Font = OsuFont.Style.Heading2, + Alpha = 0, + Margin = new MarginPadding(10), + }; + } } protected override void LoadComplete() @@ -51,6 +73,19 @@ protected override void LoadComplete() base.LoadComplete(); selectedMods.BindValueChanged(_ => selectedModsChanged(), true); + + Active.BindValueChanged(active => + { + shortcutKeyText?.FadeTo(active.NewValue ? 1 : 0.4f, TRANSITION_DURATION, Easing.OutQuint); + }, true); + } + + public void Toggle() + { + if (!Active.Value) + Select(); + else + Deselect(); } protected override void Select() diff --git a/osu.Game/Overlays/Mods/SelectAllModsButton.cs b/osu.Game/Overlays/Mods/SelectAllModsButton.cs index 1da762d164f6..2fb72ef2c423 100644 --- a/osu.Game/Overlays/Mods/SelectAllModsButton.cs +++ b/osu.Game/Overlays/Mods/SelectAllModsButton.cs @@ -18,8 +18,9 @@ public partial class SelectAllModsButton : ShearedButton private readonly Bindable searchTerm = new Bindable(); public SelectAllModsButton(FreeModSelectOverlay modSelectOverlay) - : base(ModSelectOverlay.BUTTON_WIDTH) { + Width = ModSelectOverlay.BUTTON_WIDTH; + Text = CommonStrings.SelectAll; Action = modSelectOverlay.SelectAll; diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs index 8cec85b74853..8fa2520e8f8f 100644 --- a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs +++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs @@ -3,7 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -86,7 +85,7 @@ private partial class MusicActionToast : Toast private readonly GlobalAction action; public MusicActionToast(LocalisableString value, GlobalAction action) - : base(ToastStrings.MusicPlayback, value, string.Empty) + : base(ToastStrings.MusicPlayback, value) { this.action = action; } @@ -94,7 +93,7 @@ public MusicActionToast(LocalisableString value, GlobalAction action) [BackgroundDependencyLoader] private void load(RealmKeyBindingStore keyBindingStore) { - ShortcutText.Text = keyBindingStore.GetBindingsStringFor(action).ToUpper(); + ExtraText = keyBindingStore.GetBindingsStringFor(action); } } } diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 8bb88fc8e9c5..a5550ad3d9a7 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -337,7 +337,7 @@ public void DuckMomentarily(double delayUntilRestore, DuckParameters? parameters IDisposable duckOperation = Duck(parameters); - Scheduler.AddDelayed(() => duckOperation.Dispose(), delayUntilRestore); + Scheduler.AddDelayed(duckOperation.Dispose, delayUntilRestore); } private bool next(bool allowProtectedTracks) diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs index 8a579a5ccc8a..863e0db64863 100644 --- a/osu.Game/Overlays/News/NewsCard.cs +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -16,6 +16,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Utils; namespace osu.Game.Overlays.News { @@ -143,7 +144,7 @@ private void load(OverlayColourProvider colourProvider) }, new OsuSpriteText { - Text = date.ToLocalisableString(@"d MMM yyyy").ToUpper(), + Text = date.ToLocalisedMediumDate().ToUpper(), Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), Margin = new MarginPadding { diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index 8a2a7cee8127..dd4e1cb3b0de 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -76,7 +76,7 @@ public abstract partial class Notification : Container protected override Container Content => content; - protected Container MainContent; + public Container MainContent; private readonly DragContainer dragContainer; diff --git a/osu.Game/Overlays/Notifications/UserAvatarNotification.cs b/osu.Game/Overlays/Notifications/UserAvatarNotification.cs index 7dbecbf11e2f..8ea79623c7f7 100644 --- a/osu.Game/Overlays/Notifications/UserAvatarNotification.cs +++ b/osu.Game/Overlays/Notifications/UserAvatarNotification.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -11,14 +12,13 @@ namespace osu.Game.Overlays.Notifications { public abstract partial class UserAvatarNotification : SimpleNotification { - private readonly APIUser? user; + protected readonly APIUser User; protected DrawableAvatar Avatar { get; private set; } = null!; - protected UserAvatarNotification(APIUser? user, LocalisableString text = default) + protected UserAvatarNotification(APIUser user, LocalisableString text = default) { - this.user = user; - + User = user; Icon = default; Text = text; } @@ -30,7 +30,7 @@ private void load() IconContent.CornerRadius = CORNER_RADIUS; IconContent.ChangeChildDepth(IconDrawable, float.MinValue); - LoadComponentAsync(Avatar = new DrawableAvatar(user) + LoadComponentAsync(Avatar = new DrawableAvatar(User) { FillMode = FillMode.Fill, }, IconContent.Add); @@ -39,7 +39,7 @@ private void load() protected override void Update() { base.Update(); - IconContent.Width = IconContent.DrawHeight; + IconContent.Width = Math.Min(78, IconContent.DrawHeight); } } } diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index a58aa27e2433..2f6be51cd576 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -29,6 +29,8 @@ namespace osu.Game.Overlays { public partial class NowPlayingOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent { + public const double TRACK_DRAG_SEEK_DEBOUNCE = 40; + public IconUsage Icon => OsuIcon.Music; public LocalisableString Title => NowPlayingStrings.HeaderTitle; public LocalisableString Description => NowPlayingStrings.HeaderDescription; @@ -207,7 +209,8 @@ private void load() Height = progress_height / 2, FillColour = colours.Yellow, BackgroundColour = colours.YellowDarker.Opacity(0.5f), - OnSeek = musicController.SeekTo + OnSeek = onSeek, + OnCommit = onCommit, } }, }, @@ -221,6 +224,29 @@ private void load() }; } + private double? lastSeekGameTime; + private double? lastSeekAudioTargetTime; + + private void onSeek(double progress) + { + if (!musicController.IsPlaying || lastSeekGameTime == null || Time.Current - lastSeekGameTime > TRACK_DRAG_SEEK_DEBOUNCE) + { + musicController.SeekTo(progress); + lastSeekGameTime = Time.Current; + lastSeekAudioTargetTime = progress; + } + } + + private void onCommit(double progress) + { + // Avoid a second seek to the same location, which could occur when using keyboard navigation from `OnSeek` and subsequent `OnCommit` calls. + if (progress != lastSeekAudioTargetTime) + musicController.SeekTo(progress); + + lastSeekGameTime = null; + lastSeekAudioTargetTime = null; + } + private void togglePlaylist() { if (playlist == null) @@ -304,18 +330,21 @@ protected override void Update() var track = musicController.CurrentTrack; - if (!track.IsDummyDevice) + if (!progressBar.Seeking) { - progressBar.EndTime = track.Length; - progressBar.CurrentTime = track.CurrentTime; + if (!track.IsDummyDevice) + { + progressBar.EndTime = track.Length; + progressBar.CurrentTime = track.CurrentTime; - playButton.Icon = track.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; - } - else - { - progressBar.CurrentTime = 0; - progressBar.EndTime = 1; - playButton.Icon = FontAwesome.Regular.PlayCircle; + playButton.Icon = track.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; + } + else + { + progressBar.CurrentTime = 0; + progressBar.EndTime = 1; + playButton.Icon = FontAwesome.Regular.PlayCircle; + } } } @@ -495,6 +524,8 @@ protected override void OnDragEnd(DragEndEvent e) private partial class HoverableProgressBar : ProgressBar { + public override bool HandleNonPositionalInput => IsHovered; + public HoverableProgressBar() : base(true) { diff --git a/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs b/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs index 4059a274ad7a..455d93d7adad 100644 --- a/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs +++ b/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs @@ -8,7 +8,7 @@ namespace osu.Game.Overlays.OSD public partial class CopiedToClipboardToast : Toast { public CopiedToClipboardToast() - : base(CommonStrings.General, ToastStrings.CopiedToClipboard, "") + : base(CommonStrings.General, ToastStrings.CopiedToClipboard) { } } diff --git a/osu.Game/Overlays/OSD/SpeedChangeToast.cs b/osu.Game/Overlays/OSD/SpeedChangeToast.cs index 652c04335730..48cf6d2b0a11 100644 --- a/osu.Game/Overlays/OSD/SpeedChangeToast.cs +++ b/osu.Game/Overlays/OSD/SpeedChangeToast.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Localisation; @@ -9,9 +10,15 @@ namespace osu.Game.Overlays.OSD { public partial class SpeedChangeToast : Toast { - public SpeedChangeToast(RealmKeyBindingStore keyBindingStore, double newSpeed) - : base(ModSelectOverlayStrings.ModCustomisation, ToastStrings.SpeedChangedTo(newSpeed), keyBindingStore.GetBindingsStringFor(GlobalAction.IncreaseModSpeed) + " / " + keyBindingStore.GetBindingsStringFor(GlobalAction.DecreaseModSpeed)) + public SpeedChangeToast(double newSpeed) + : base(ModSelectOverlayStrings.ModCustomisation, ToastStrings.SpeedChangedTo(newSpeed)) { } + + [BackgroundDependencyLoader] + private void load(RealmKeyBindingStore keyBindingStore) + { + ExtraText = keyBindingStore.GetBindingsStringFor(GlobalAction.IncreaseModSpeed) + " / " + keyBindingStore.GetBindingsStringFor(GlobalAction.DecreaseModSpeed); + } } } diff --git a/osu.Game/Overlays/OSD/Toast.cs b/osu.Game/Overlays/OSD/Toast.cs index 7df534d90db9..a809c37800b6 100644 --- a/osu.Game/Overlays/OSD/Toast.cs +++ b/osu.Game/Overlays/OSD/Toast.cs @@ -10,23 +10,30 @@ using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; -using osu.Game.Localisation; namespace osu.Game.Overlays.OSD { public abstract partial class Toast : Container { + /// + /// Extra text to be shown at the bottom of the toast. Usually a key binding if available. + /// + public LocalisableString ExtraText + { + get => extraText.Text; + set => extraText.Text = value.ToUpper(); + } + private const int toast_minimum_width = 240; private readonly Container content; protected override Container Content => content; - protected readonly OsuSpriteText ValueText; - - protected readonly OsuSpriteText ShortcutText; + protected readonly OsuSpriteText ValueSpriteText; + private readonly OsuSpriteText extraText; - protected Toast(LocalisableString description, LocalisableString value, LocalisableString shortcut) + protected Toast(LocalisableString description, LocalisableString value) { Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -65,7 +72,7 @@ protected Toast(LocalisableString description, LocalisableString value, Localisa Origin = Anchor.TopCentre, Text = description.ToUpper() }, - ValueText = new OsuSpriteText + ValueSpriteText = new OsuSpriteText { Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light), Padding = new MarginPadding { Horizontal = 10 }, @@ -74,15 +81,14 @@ protected Toast(LocalisableString description, LocalisableString value, Localisa Origin = Anchor.Centre, Text = value }, - ShortcutText = new OsuSpriteText + extraText = new OsuSpriteText { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - Name = "Shortcut", + Name = "Extra Text", Alpha = 0.3f, Margin = new MarginPadding { Bottom = 15, Horizontal = 10 }, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = string.IsNullOrEmpty(shortcut.ToString()) ? ToastStrings.NoKeyBound.ToUpper() : shortcut.ToUpper() }, }; } diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 1aa6de423ef5..73fed25b1a8c 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -35,8 +35,10 @@ public partial class TrackedSettingToast : Toast private Bindable lastPlaybackTime; public TrackedSettingToast(SettingDescription description) - : base(description.Name, description.Value, description.Shortcut) + : base(description.Name, description.Value) { + ExtraText = description.Shortcut; + FillFlowContainer optionLights; Children = new Drawable[] @@ -75,7 +77,7 @@ public TrackedSettingToast(SettingDescription description) break; } - ValueText.Origin = optionCount > 0 ? Anchor.BottomCentre : Anchor.Centre; + ValueSpriteText.Origin = optionCount > 0 ? Anchor.BottomCentre : Anchor.Centre; for (int i = 0; i < optionCount; i++) optionLights.Add(new OptionLight { Glowing = i == selectedOption }); diff --git a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs index c2bea0ed911d..1ddcb778f7ed 100644 --- a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs +++ b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs @@ -29,7 +29,7 @@ public partial class OverlayPanelDisplayStyleControl : OsuTabControl false; - public OverlayPanelDisplayStyleControl() + public OverlayPanelDisplayStyleControl(bool supportsBrickMode) { AutoSizeAxes = Axes.Both; @@ -41,10 +41,14 @@ public OverlayPanelDisplayStyleControl() { Icon = FontAwesome.Solid.Bars }); - AddTabItem(new PanelDisplayTabItem(OverlayPanelDisplayStyle.Brick) + + if (supportsBrickMode) { - Icon = FontAwesome.Solid.Th - }); + AddTabItem(new PanelDisplayTabItem(OverlayPanelDisplayStyle.Brick) + { + Icon = FontAwesome.Solid.Th + }); + } } protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index 957008d823d3..a197748687ee 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -14,6 +15,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -36,6 +38,7 @@ public partial class OverlayScrollContainer : UserTrackingScrollContainer public ScrollBackButton Button { get; private set; } private readonly Bindable lastScrollTarget = new Bindable(); + private readonly Bindable progress = new Bindable(); [BackgroundDependencyLoader] private void load() @@ -46,7 +49,8 @@ private void load() Origin = Anchor.BottomRight, Margin = new MarginPadding(20), Action = scrollBack, - LastScrollTarget = { BindTarget = lastScrollTarget } + LastScrollTarget = { BindTarget = lastScrollTarget }, + Progress = { BindTarget = progress }, }); } @@ -54,6 +58,10 @@ protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); + // Map current position to standardized progress + float height = AvailableContent - DrawHeight; + progress.Value = height == 0 ? 1 : Math.Round(Math.Clamp(Current / height, 0, 1), 3); + if (ScrollContent.DrawHeight + button_scroll_position < DrawHeight) { Button.State = Visibility.Hidden; @@ -110,9 +118,11 @@ public Visibility State private readonly Container content; private readonly Box background; + private readonly CircularProgress currentCircularProgress; private readonly SpriteIcon spriteIcon; public Bindable LastScrollTarget = new Bindable(); + public Bindable Progress = new Bindable(); protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); @@ -145,6 +155,11 @@ public ScrollBackButton() { RelativeSizeAxes = Axes.Both }, + currentCircularProgress = new CircularProgress + { + RelativeSizeAxes = Axes.Both, + InnerRadius = 0.1f, + }, spriteIcon = new SpriteIcon { Anchor = Anchor.Centre, @@ -164,6 +179,7 @@ private void load(OverlayColourProvider colourProvider, AudioManager audio) IdleColour = colourProvider.Background6; HoverColour = colourProvider.Background5; flashColour = colourProvider.Light1; + currentCircularProgress.Colour = colourProvider.Highlight1; scrollToTopSample = audio.Samples.Get(@"UI/scroll-to-top"); scrollToPreviousSample = audio.Samples.Get(@"UI/scroll-to-previous"); @@ -173,6 +189,8 @@ protected override void LoadComplete() { base.LoadComplete(); + Progress.BindValueChanged(p => currentCircularProgress.Progress = p.NewValue, true); + LastScrollTarget.BindValueChanged(target => { spriteIcon.ScaleTo(target.NewValue != null ? new Vector2(1f, -1f) : Vector2.One, fade_duration, Easing.OutQuint); diff --git a/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs index 356098692558..2e5374fdb7cc 100644 --- a/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs @@ -13,6 +13,7 @@ using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; using osu.Game.Users; +using osu.Game.Utils; namespace osu.Game.Overlays.Profile.Header.Components { @@ -75,19 +76,19 @@ private void updateState() if (percent < 0.0005) return RankingTier.Radiant; - if (percent < 0.0025) + if (percent < 0.0015) return RankingTier.Rhodium; if (percent < 0.005) return RankingTier.Platinum; - if (percent < 0.025) + if (percent < 0.015) return RankingTier.Gold; if (percent < 0.05) return RankingTier.Silver; - if (percent < 0.25) + if (percent < 0.15) return RankingTier.Bronze; if (percent < 0.5) @@ -123,7 +124,7 @@ private LocalisableString getGlobalRankTooltipText() { var rankHighestText = UsersStrings.ShowRankHighest( rankHighest.Rank.ToLocalisableString("\\##,##0"), - rankHighest.UpdatedAt.ToLocalisableString(@"d MMM yyyy")); + rankHighest.UpdatedAt.ToLocalisedMediumDate()); if (result == null) result = rankHighestText; diff --git a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs index 1cd09566fba4..f286501e6e87 100644 --- a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs @@ -127,6 +127,9 @@ private void onUserChanged(ValueChangedEvent user) Hide(); } + protected override bool OnHover(HoverEvent e) => true; + protected override bool OnClick(ClickEvent e) => true; + protected override void OnHoverLost(HoverLostEvent e) { base.OnHoverLost(e); diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileActionPopover.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileActionPopover.cs new file mode 100644 index 000000000000..11f82772db48 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileActionPopover.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public abstract partial class ProfileActionPopover : OsuPopover + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private FillFlowContainer container = null!; + + protected ProfileActionPopover() + : base(false) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Background.Colour = colourProvider.Background6; + + AllowableAnchors = [Anchor.BottomCentre, Anchor.TopCentre]; + + Child = container = new FillFlowContainer + { + Width = 160, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, + }; + } + + public ProfilePopoverAction[] Actions { set => container.Children = value; } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileActionsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileActionsButton.cs new file mode 100644 index 000000000000..ff6a279be2dc --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileActionsButton.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public abstract partial class ProfileActionsButton : OsuHoverContainer, IHasPopover + { + private Box background = null!; + + protected override IEnumerable EffectTargets => [background]; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + IdleColour = colourProvider.Background2; + HoverColour = colourProvider.Background1; + + Size = new Vector2(40); + Masking = true; + CornerRadius = 20; + + Child = new CircularContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new SpriteIcon + { + Size = new Vector2(12), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.EllipsisV, + }, + } + }; + + Action = this.ShowPopover; + } + + public abstract Popover GetPopover(); + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfilePopoverAction.cs b/osu.Game/Overlays/Profile/Header/Components/ProfilePopoverAction.cs new file mode 100644 index 000000000000..5dd8b8050260 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/ProfilePopoverAction.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class ProfilePopoverAction : OsuClickableContainer + { + private readonly IconUsage icon; + private readonly LocalisableString caption; + + private Box background = null!; + private CircularContainer indicator = null!; + + public ProfilePopoverAction(IconUsage icon, LocalisableString caption) + { + this.icon = icon; + this.caption = caption; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Content.RelativeSizeAxes = Axes.X; + AutoSizeAxes = Content.AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 4; + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + Alpha = 0, + }, + indicator = new Circle + { + Width = 4, + Height = 14, + X = 10, + Colour = colourProvider.Highlight1, + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Alpha = 0, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 25, Vertical = 5 }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new SpriteIcon + { + Icon = icon, + Size = new Vector2(11), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new OsuSpriteText + { + Text = caption, + Font = OsuFont.Style.Body, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + } + } + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + background.Alpha = indicator.Alpha = IsHovered ? 1 : 0; + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/UserActionsButton.cs b/osu.Game/Overlays/Profile/Header/Components/UserActionsButton.cs index 1a2593cff7b7..7c4e47206a0c 100644 --- a/osu.Game/Overlays/Profile/Header/Components/UserActionsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/UserActionsButton.cs @@ -1,78 +1,26 @@ // 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.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; using osu.Game.Users; -using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { - public partial class UserActionsButton : OsuHoverContainer, IHasPopover + public partial class UserActionsButton : ProfileActionsButton { public readonly Bindable User = new Bindable(); - private Box background = null!; - - protected override IEnumerable EffectTargets => [background]; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - [Resolved] private IAPIProvider api { get; set; } = null!; - [BackgroundDependencyLoader] - private void load() - { - IdleColour = colourProvider.Background2; - HoverColour = colourProvider.Background1; - - Size = new Vector2(40); - Masking = true; - CornerRadius = 20; - - Child = new CircularContainer - { - Masking = true, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - }, - new SpriteIcon - { - Size = new Vector2(12), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = FontAwesome.Solid.EllipsisV, - }, - } - }; - - Action = this.ShowPopover; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -80,132 +28,34 @@ protected override void LoadComplete() User.BindValueChanged(_ => Alpha = User.Value?.User.OnlineID == api.LocalUser.Value.OnlineID ? 0 : 1, true); } - public Popover GetPopover() => new UserActionPopover(User.Value!.User); + public override Popover GetPopover() => new UserActionPopover(User.Value!.User); - private partial class UserActionPopover : OsuPopover + private partial class UserActionPopover : ProfileActionPopover { private readonly APIUser user; public UserActionPopover(APIUser user) - : base(false) { this.user = user; } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, IAPIProvider api, IDialogOverlay? dialogOverlay) + private void load(IAPIProvider api, IDialogOverlay? dialogOverlay) { - Background.Colour = colourProvider.Background6; - bool userBlocked = api.LocalUserState.Blocks.Any(b => b.TargetID == user.Id); - AllowableAnchors = [Anchor.BottomCentre, Anchor.TopCentre]; - - Child = new FillFlowContainer + Actions = new[] { - Width = 160, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Children = new Drawable[] + new ProfilePopoverAction(FontAwesome.Solid.Ban, userBlocked ? UsersStrings.BlocksButtonUnblock : UsersStrings.BlocksButtonBlock) { - new UserAction(FontAwesome.Solid.Ban, userBlocked ? UsersStrings.BlocksButtonUnblock : UsersStrings.BlocksButtonBlock) + Action = () => { - Action = () => - { - dialogOverlay?.Push(userBlocked ? ConfirmBlockActionDialog.Unblock(user) : ConfirmBlockActionDialog.Block(user)); - this.HidePopover(); - } + dialogOverlay?.Push(userBlocked ? ConfirmBlockActionDialog.Unblock(user) : ConfirmBlockActionDialog.Block(user)); + this.HidePopover(); } } }; } } - - private partial class UserAction : OsuClickableContainer - { - private readonly IconUsage icon; - private readonly LocalisableString caption; - - private Box background = null!; - private CircularContainer indicator = null!; - - public UserAction(IconUsage icon, LocalisableString caption) - { - this.icon = icon; - this.caption = caption; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - RelativeSizeAxes = Content.RelativeSizeAxes = Axes.X; - AutoSizeAxes = Content.AutoSizeAxes = Axes.Y; - - Masking = true; - CornerRadius = 4; - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - Alpha = 0, - }, - indicator = new Circle - { - Width = 4, - Height = 14, - X = 10, - Colour = colourProvider.Highlight1, - Anchor = Anchor.CentreLeft, - Origin = Anchor.Centre, - Alpha = 0, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 25, Vertical = 5 }, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), - Children = new Drawable[] - { - new SpriteIcon - { - Icon = icon, - Size = new Vector2(11), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - new OsuSpriteText - { - Text = caption, - Font = OsuFont.Style.Body, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - UseFullGlyphHeight = false, - } - } - } - }; - } - - protected override bool OnHover(HoverEvent e) - { - updateState(); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateState(); - base.OnHoverLost(e); - } - - private void updateState() - { - background.Alpha = indicator.Alpha = IsHovered ? 1 : 0; - } - } } } diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index 3d9539ce1fec..67dce43f04bf 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -48,7 +48,8 @@ public partial class TopHeaderContainer : CompositeDrawable private OsuSpriteText teamText = null!; private GroupBadgeFlow groupBadgeFlow = null!; private ToggleCoverButton coverToggle = null!; - private PreviousUsernamesDisplay previousUsernamesDisplay = null!; + + public PreviousUsernamesDisplay PreviousUsernamesDisplay { get; } = new PreviousUsernamesDisplay(); private Bindable coverExpanded = null!; @@ -149,7 +150,7 @@ private void load(OverlayColourProvider colourProvider, OsuConfigManager configM new Container { // Intentionally use a zero-size container, else the fill flow will adjust to (and cancel) the upwards animation. - Child = previousUsernamesDisplay = new PreviousUsernamesDisplay(), + Child = PreviousUsernamesDisplay, } } }, @@ -254,7 +255,7 @@ private void updateUser(UserProfileData? data) titleText.Text = user?.Title ?? string.Empty; titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); groupBadgeFlow.User.Value = user; - previousUsernamesDisplay.User.Value = user; + PreviousUsernamesDisplay.User.Value = user; } private void updateCoverState() diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index 42bec5002268..bdcec277af8e 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -20,6 +20,8 @@ public partial class ProfileHeader : TabControlOverlayHeader private CentreHeaderContainer centreHeaderContainer; private DetailHeaderContainer detailHeaderContainer; + private TopHeaderContainer topHeaderContainer = null!; + public ProfileHeader() { ContentSidePadding = WaveOverlayContainer.HORIZONTAL_PADDING; @@ -43,7 +45,7 @@ public ProfileHeader() Direction = FillDirection.Vertical, Children = new Drawable[] { - new TopHeaderContainer + topHeaderContainer = new TopHeaderContainer { RelativeSizeAxes = Axes.X, User = { BindTarget = User }, @@ -75,6 +77,15 @@ public ProfileHeader() } }; + protected override void LoadComplete() + { + base.LoadComplete(); + + // This is basically a tooltip display on hover, so we should display above everything. + // If this ever breaks let's just trash the design and make it a standard tooltip. + AddInternal(topHeaderContainer.PreviousUsernamesDisplay.CreateProxy()); + } + protected override OverlayTitle CreateTitle() => new ProfileHeaderTitle(); protected override Drawable CreateTabControlContent() => new ProfileRulesetSelector diff --git a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs index 0eaa6ce82780..63f0d5eb766d 100644 --- a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs +++ b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs @@ -54,7 +54,7 @@ bool showRulesetSelector(RankingsScope scope) case RankingsScope.Performance: case RankingsScope.Score: case RankingsScope.Country: - case RankingsScope.Spotlights: + case RankingsScope.Playlists: return true; default: diff --git a/osu.Game/Overlays/Rankings/RankingsScope.cs b/osu.Game/Overlays/Rankings/RankingsScope.cs index 658732a1b154..6822783b0412 100644 --- a/osu.Game/Overlays/Rankings/RankingsScope.cs +++ b/osu.Game/Overlays/Rankings/RankingsScope.cs @@ -17,8 +17,8 @@ public enum RankingsScope [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCountry))] Country, - [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCharts))] - Spotlights, + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypePlaylists))] + Playlists, [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeKudosu))] Kudosu, diff --git a/osu.Game/Overlays/RankingsOverlay.cs b/osu.Game/Overlays/RankingsOverlay.cs index 6a32515cbc1e..71f0010d42e1 100644 --- a/osu.Game/Overlays/RankingsOverlay.cs +++ b/osu.Game/Overlays/RankingsOverlay.cs @@ -52,7 +52,7 @@ protected override void LoadComplete() ruleset.BindValueChanged(_ => { - if (Header.Current.Value == RankingsScope.Spotlights) + if (Header.Current.Value == RankingsScope.Playlists) return; Scheduler.AddOnce(triggerTabChanged); @@ -99,7 +99,7 @@ protected override void CreateDisplayToLoad(RankingsScope tab) { lastRequest?.Cancel(); - if (Header.Current.Value == RankingsScope.Spotlights) + if (Header.Current.Value == RankingsScope.Playlists) { LoadDisplay(new SpotlightsLayout { diff --git a/osu.Game/Overlays/Settings/DangerousSettingsButtonV2.cs b/osu.Game/Overlays/Settings/DangerousSettingsButtonV2.cs new file mode 100644 index 000000000000..9c2c73944414 --- /dev/null +++ b/osu.Game/Overlays/Settings/DangerousSettingsButtonV2.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Graphics; + +namespace osu.Game.Overlays.Settings +{ + /// + /// A with pink colours to mark dangerous/destructive actions. + /// + public partial class DangerousSettingsButtonV2 : SettingsButtonV2 + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundColour = colours.DangerousButtonColour; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index 5b5617bae013..5742272e9676 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -7,9 +7,10 @@ using System.Collections.Generic; using System.Linq; using osu.Framework; +using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Localisation; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio @@ -21,30 +22,37 @@ public partial class AudioDevicesSettings : SettingsSubsection [Resolved] private AudioManager audio { get; set; } = null!; - private SettingsDropdown dropdown = null!; + private AudioDeviceDropdown dropdown = null!; - private SettingsCheckbox? wasapiExperimental; + private FormCheckBox? wasapiExperimental; + + private readonly Bindable wasapiExperimentalNote = new Bindable(); [BackgroundDependencyLoader] private void load() { Children = new Drawable[] { - dropdown = new AudioDeviceSettingsDropdown + new SettingsItemV2(dropdown = new AudioDeviceDropdown + { + Caption = AudioSettingsStrings.OutputDevice, + }) { - LabelText = AudioSettingsStrings.OutputDevice, Keywords = new[] { "speaker", "headphone", "output" } }, }; if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) { - Add(wasapiExperimental = new SettingsCheckbox + Add(new SettingsItemV2(wasapiExperimental = new FormCheckBox { - LabelText = "Use experimental audio mode", - TooltipText = "This will attempt to initialise the audio engine in a lower latency mode.", + Caption = AudioSettingsStrings.WasapiLabel, + HintText = AudioSettingsStrings.WasapiTooltip, Current = audio.UseExperimentalWasapi, - Keywords = new[] { "wasapi", "latency", "exclusive" } + }) + { + Keywords = new[] { "wasapi", "latency", "exclusive" }, + Note = { BindTarget = wasapiExperimentalNote }, }); wasapiExperimental.Current.ValueChanged += _ => onDeviceChanged(string.Empty); @@ -64,12 +72,9 @@ private void onDeviceChanged(string _) if (wasapiExperimental != null) { if (wasapiExperimental.Current.Value) - { - wasapiExperimental.SetNoticeText( - "Due to reduced latency, your audio offset will need to be adjusted when enabling this setting. Generally expect to subtract 20 - 60 ms from your known value.", true); - } + wasapiExperimentalNote.Value = new SettingsNote.Data(AudioSettingsStrings.WasapiNotice, SettingsNote.Type.Warning); else - wasapiExperimental.ClearNoticeText(); + wasapiExperimentalNote.Value = null; } } @@ -106,15 +111,10 @@ protected override void Dispose(bool isDisposing) } } - private partial class AudioDeviceSettingsDropdown : SettingsDropdown + private partial class AudioDeviceDropdown : FormDropdown { - protected override OsuDropdown CreateDropdown() => new AudioDeviceDropdownControl(); - - private partial class AudioDeviceDropdownControl : DropdownControl - { - protected override LocalisableString GenerateItemText(string item) - => string.IsNullOrEmpty(item) ? CommonStrings.Default : base.GenerateItemText(item); - } + protected override LocalisableString GenerateItemText(string item) + => string.IsNullOrEmpty(item) ? CommonStrings.Default : base.GenerateItemText(item); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs index 6e5e0105184e..df9646d211ae 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs @@ -11,13 +11,8 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Extensions; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Screens.Play.PlayerSettings; @@ -25,157 +20,164 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { - public partial class AudioOffsetAdjustControl : SettingsItem + public partial class AudioOffsetAdjustControl : CompositeDrawable { - public IBindable SuggestedOffset => ((AudioOffsetPreview)Control).SuggestedOffset; - - [BackgroundDependencyLoader] - private void load() + public Bindable Current { - LabelText = AudioSettingsStrings.AudioOffset; + get => current.Current; + set => current.Current = value; } - protected override Drawable CreateControl() => new AudioOffsetPreview(); + private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(); - private partial class AudioOffsetPreview : CompositeDrawable, IHasCurrentValue - { - public Bindable Current - { - get => current.Current; - set => current.Current = value; - } + private readonly IBindableList averageHitErrorHistory = new BindableList(); - private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(); + public readonly Bindable SuggestedOffset = new Bindable(); - private readonly IBindableList averageHitErrorHistory = new BindableList(); + private Container notchContainer = null!; + private SettingsNote hintNote = null!; + private RoundedButton applySuggestion = null!; - public readonly Bindable SuggestedOffset = new Bindable(); + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; - private Container notchContainer = null!; - private TextFlowContainer hintText = null!; - private RoundedButton applySuggestion = null!; + [BackgroundDependencyLoader] + private void load(SessionAverageHitErrorTracker hitErrorTracker) + { + averageHitErrorHistory.BindTo(hitErrorTracker.AverageHitErrorHistory); - [BackgroundDependencyLoader] - private void load(SessionAverageHitErrorTracker hitErrorTracker) + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = new FillFlowContainer { - averageHitErrorHistory.BindTo(hitErrorTracker.AverageHitErrorHistory); - - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - InternalChild = new FillFlowContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(SettingsSection.ITEM_SPACING_V2), + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), - Direction = FillDirection.Vertical, - Children = new Drawable[] + new SettingsItemV2(new FormSliderBar { - new OffsetSliderBar - { - RelativeSizeAxes = Axes.X, - Current = { BindTarget = Current }, - KeyboardStep = 1, - }, - notchContainer = new Container - { - RelativeSizeAxes = Axes.X, - Height = 10, - Padding = new MarginPadding { Horizontal = Nub.DEFAULT_EXPANDED_SIZE / 2 }, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - hintText = new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 16)) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - applySuggestion = new RoundedButton + Caption = AudioSettingsStrings.AudioOffset, + RelativeSizeAxes = Axes.X, + Current = { BindTarget = Current }, + KeyboardStep = 1, + LabelFormat = v => $"{v:N0} ms", + TooltipFormat = BeatmapOffsetControl.GetOffsetExplanatoryText, + }), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = SettingsPanel.CONTENT_PADDING, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - Text = AudioSettingsStrings.ApplySuggestedOffset, - Action = () => + notchContainer = new Container { - if (SuggestedOffset.Value.HasValue) - current.Value = SuggestedOffset.Value.Value; - hitErrorTracker.ClearHistory(); - } + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Height = 10, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Padding = new MarginPadding + { + Horizontal = FormSliderBar.InnerSlider.NUB_WIDTH / 2 + }, + }, + hintNote = new SettingsNote { RelativeSizeAxes = Axes.X }, + } + }, + applySuggestion = new RoundedButton + { + RelativeSizeAxes = Axes.X, + Text = AudioSettingsStrings.ApplySuggestedOffset, + Padding = SettingsPanel.CONTENT_PADDING, + Action = () => + { + if (SuggestedOffset.Value.HasValue) + current.Value = SuggestedOffset.Value.Value; + hitErrorTracker.ClearHistory(); } } - }; - } + } + }; + } - protected override void LoadComplete() - { - base.LoadComplete(); + protected override void LoadComplete() + { + base.LoadComplete(); - averageHitErrorHistory.BindCollectionChanged(updateDisplay, true); - current.BindValueChanged(_ => updateHintText()); - SuggestedOffset.BindValueChanged(_ => updateHintText(), true); - } + averageHitErrorHistory.BindCollectionChanged(updateDisplay, true); + current.BindValueChanged(_ => updateHintText()); + SuggestedOffset.BindValueChanged(_ => updateHintText(), true); + } - private void updateDisplay(object? _, NotifyCollectionChangedEventArgs e) + private void updateDisplay(object? _, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - foreach (SessionAverageHitErrorTracker.DataPoint dataPoint in e.NewItems!) + case NotifyCollectionChangedAction.Add: + foreach (SessionAverageHitErrorTracker.DataPoint dataPoint in e.NewItems!) + { + notchContainer.ForEach(n => n.Alpha *= 0.95f); + notchContainer.Add(new Circle { - notchContainer.ForEach(n => n.Alpha *= 0.95f); - notchContainer.Add(new Box - { - RelativeSizeAxes = Axes.Y, - Width = 2, - RelativePositionAxes = Axes.X, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - X = getXPositionForOffset(dataPoint.SuggestedGlobalAudioOffset) - }); - } - - break; + RelativeSizeAxes = Axes.Y, + Width = 2, + RelativePositionAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colourProvider.Light1, + X = getXPositionForOffset(dataPoint.SuggestedGlobalAudioOffset) + }); + } - case NotifyCollectionChangedAction.Remove: - foreach (SessionAverageHitErrorTracker.DataPoint dataPoint in e.OldItems!) - { - var notch = notchContainer.FirstOrDefault(n => n.X == getXPositionForOffset(dataPoint.SuggestedGlobalAudioOffset)); - Debug.Assert(notch != null); - notchContainer.Remove(notch, true); - } + break; - break; + case NotifyCollectionChangedAction.Remove: + foreach (SessionAverageHitErrorTracker.DataPoint dataPoint in e.OldItems!) + { + var notch = notchContainer.FirstOrDefault(n => n.X == getXPositionForOffset(dataPoint.SuggestedGlobalAudioOffset)); + Debug.Assert(notch != null); + notchContainer.Remove(notch, true); + } - case NotifyCollectionChangedAction.Reset: - notchContainer.Clear(); - break; - } + break; - SuggestedOffset.Value = averageHitErrorHistory.Any() ? Math.Round(averageHitErrorHistory.Average(dataPoint => dataPoint.SuggestedGlobalAudioOffset)) : null; + case NotifyCollectionChangedAction.Reset: + notchContainer.Clear(); + break; } - private float getXPositionForOffset(double offset) => (float)(Math.Clamp(offset, current.MinValue, current.MaxValue) / (2 * current.MaxValue)); + SuggestedOffset.Value = averageHitErrorHistory.Any() ? Math.Round(averageHitErrorHistory.Average(dataPoint => dataPoint.SuggestedGlobalAudioOffset)) : null; + } - private void updateHintText() + private float getXPositionForOffset(double offset) => (float)(Math.Clamp(offset, current.MinValue, current.MaxValue) / (2 * current.MaxValue)); + + private void updateHintText() + { + if (SuggestedOffset.Value == null) { - if (SuggestedOffset.Value == null) - { - applySuggestion.Enabled.Value = false; - hintText.Text = AudioSettingsStrings.SuggestedOffsetNote; - } - else if (Math.Abs(SuggestedOffset.Value.Value - current.Value) < 1) - { - applySuggestion.Enabled.Value = false; - hintText.Text = AudioSettingsStrings.SuggestedOffsetCorrect(averageHitErrorHistory.Count); - } - else - { - applySuggestion.Enabled.Value = true; - hintText.Text = AudioSettingsStrings.SuggestedOffsetValueReceived(averageHitErrorHistory.Count, SuggestedOffset.Value.Value.ToStandardFormattedString(0)); - } + applySuggestion.Enabled.Value = false; + notchContainer.Hide(); + hintNote.Current.Value = new SettingsNote.Data(AudioSettingsStrings.SuggestedOffsetNote, SettingsNote.Type.Informational); + hintNote.MoveToY(0, 200, Easing.OutQuint); } - - private partial class OffsetSliderBar : RoundedSliderBar + else if (Math.Abs(SuggestedOffset.Value.Value - current.Value) < 1) + { + applySuggestion.Enabled.Value = false; + notchContainer.Show(); + hintNote.Current.Value = new SettingsNote.Data(AudioSettingsStrings.SuggestedOffsetCorrect(averageHitErrorHistory.Count), SettingsNote.Type.Informational); + hintNote.MoveToY(10, 200, Easing.OutQuint); + } + else { - public override LocalisableString TooltipText => BeatmapOffsetControl.GetOffsetExplanatoryText(Current.Value); + applySuggestion.Enabled.Value = true; + notchContainer.Show(); + hintNote.Current.Value = + new SettingsNote.Data(AudioSettingsStrings.SuggestedOffsetValueReceived(averageHitErrorHistory.Count, SuggestedOffset.Value.Value.ToStandardFormattedString(0)), + SettingsNote.Type.Informational); + hintNote.MoveToY(10, 200, Easing.OutQuint); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index b839c98f9f12..1cfba9632f09 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio @@ -25,13 +26,14 @@ private void load(OsuConfigManager config) new AudioOffsetAdjustControl { Current = config.GetBindable(OsuSetting.AudioOffset), + Margin = new MarginPadding { Bottom = 5 }, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = AudioSettingsStrings.AdjustBeatmapOffsetAutomatically, - TooltipText = AudioSettingsStrings.AdjustBeatmapOffsetAutomaticallyTooltip, + Caption = AudioSettingsStrings.AdjustBeatmapOffsetAutomatically, + HintText = AudioSettingsStrings.AdjustBeatmapOffsetAutomaticallyTooltip, Current = config.GetBindable(OsuSetting.AutomaticallyAdjustBeatmapOffset), - } + }) }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index 2bb5fa983f54..c0d38e50c151 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -6,7 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio @@ -20,46 +20,38 @@ private void load(AudioManager audio, OsuConfigManager config) { Children = new Drawable[] { - new VolumeAdjustSlider + new SettingsItemV2(new FormSliderBar { - LabelText = AudioSettingsStrings.MasterVolume, + Caption = AudioSettingsStrings.MasterVolume, Current = audio.Volume, KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsSlider + DisplayAsPercentage = true, + PlaySamplesOnAdjust = false, + }), + new SettingsItemV2(new FormSliderBar { - LabelText = AudioSettingsStrings.MasterVolumeInactive, + Caption = AudioSettingsStrings.MasterVolumeInactive, Current = config.GetBindable(OsuSetting.VolumeInactive), KeyboardStep = 0.01f, DisplayAsPercentage = true - }, - new VolumeAdjustSlider + }), + new SettingsItemV2(new FormSliderBar { - LabelText = AudioSettingsStrings.EffectVolume, + Caption = AudioSettingsStrings.EffectVolume, Current = audio.VolumeSample, KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - - new VolumeAdjustSlider + DisplayAsPercentage = true, + PlaySamplesOnAdjust = false, + }), + new SettingsItemV2(new FormSliderBar { - LabelText = AudioSettingsStrings.MusicVolume, + Caption = AudioSettingsStrings.MusicVolume, Current = audio.VolumeTrack, KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, + DisplayAsPercentage = true, + PlaySamplesOnAdjust = false, + }), }; } - - private partial class VolumeAdjustSlider : SettingsSlider - { - protected override Drawable CreateControl() - { - var sliderBar = (RoundedSliderBar)base.CreateControl(); - sliderBar.PlaySamplesOnAdjust = false; - return sliderBar; - } - } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/BatchImportSettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/BatchImportSettings.cs index 1c1735631313..86455c458591 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/BatchImportSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/BatchImportSettings.cs @@ -11,10 +11,10 @@ public partial class BatchImportSettings : SettingsSubsection { protected override LocalisableString Header => @"Batch Import"; - private SettingsButton importBeatmapsButton = null!; - private SettingsButton importCollectionsButton = null!; - private SettingsButton importScoresButton = null!; - private SettingsButton importSkinsButton = null!; + private SettingsButtonV2 importBeatmapsButton = null!; + private SettingsButtonV2 importCollectionsButton = null!; + private SettingsButtonV2 importScoresButton = null!; + private SettingsButtonV2 importSkinsButton = null!; [BackgroundDependencyLoader] private void load(LegacyImportManager? legacyImportManager) @@ -24,7 +24,7 @@ private void load(LegacyImportManager? legacyImportManager) AddRange(new[] { - importBeatmapsButton = new SettingsButton + importBeatmapsButton = new SettingsButtonV2 { Text = @"Import beatmaps from stable", Action = () => @@ -33,7 +33,7 @@ private void load(LegacyImportManager? legacyImportManager) legacyImportManager.ImportFromStableAsync(StableContent.Beatmaps).ContinueWith(_ => Schedule(() => importBeatmapsButton.Enabled.Value = true)); } }, - importSkinsButton = new SettingsButton + importSkinsButton = new SettingsButtonV2 { Text = @"Import skins from stable", Action = () => @@ -42,7 +42,7 @@ private void load(LegacyImportManager? legacyImportManager) legacyImportManager.ImportFromStableAsync(StableContent.Skins).ContinueWith(_ => Schedule(() => importSkinsButton.Enabled.Value = true)); } }, - importCollectionsButton = new SettingsButton + importCollectionsButton = new SettingsButtonV2 { Text = @"Import collections from stable", Action = () => @@ -51,7 +51,7 @@ private void load(LegacyImportManager? legacyImportManager) legacyImportManager.ImportFromStableAsync(StableContent.Collections).ContinueWith(_ => Schedule(() => importCollectionsButton.Enabled.Value = true)); } }, - importScoresButton = new SettingsButton + importScoresButton = new SettingsButtonV2 { Text = @"Import scores from stable", Action = () => diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs index 914fc9d141c6..04b63ccb844e 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings.Sections.DebugSettings { @@ -14,17 +15,17 @@ public partial class GeneralSettings : SettingsSubsection [BackgroundDependencyLoader] private void load(FrameworkDebugConfigManager config, FrameworkConfigManager frameworkConfig) { - Add(new SettingsCheckbox + Add(new SettingsItemV2(new FormCheckBox { - LabelText = @"Show log overlay", + Caption = @"Show log overlay", Current = frameworkConfig.GetBindable(FrameworkSetting.ShowLogOverlay) - }); + })); - Add(new SettingsCheckbox + Add(new SettingsItemV2(new FormCheckBox { - LabelText = @"Bypass front-to-back render pass", + Caption = @"Bypass front-to-back render pass", Current = config.GetBindable(DebugSetting.BypassFrontToBackPass) - }); + })); } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs index 7b9b88a213fd..63b09872b763 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs @@ -13,6 +13,7 @@ using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Database; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings.Sections.DebugSettings { @@ -23,10 +24,10 @@ public partial class MemorySettings : SettingsSubsection [BackgroundDependencyLoader] private void load(GameHost host, RealmAccess realm) { - SettingsButton blockAction; - SettingsButton unblockAction; + SettingsButtonV2 blockAction; + SettingsButtonV2 unblockAction; - Add(new SettingsButton + Add(new SettingsButtonV2 { Text = @"Clear all caches", Action = () => @@ -38,11 +39,11 @@ private void load(GameHost host, RealmAccess realm) } }); - SettingsEnumDropdown latencyModeDropdown; - Add(latencyModeDropdown = new SettingsEnumDropdown + FormEnumDropdown latencyModeDropdown; + Add(new SettingsItemV2(latencyModeDropdown = new FormEnumDropdown { - LabelText = "GC mode", - }); + Caption = "GC mode", + })); latencyModeDropdown.Current.BindValueChanged(mode => { @@ -65,7 +66,7 @@ private void load(GameHost host, RealmAccess realm) { AddRange(new Drawable[] { - new SettingsButton + new SettingsButtonV2 { Text = @"Compact realm", Action = () => @@ -76,11 +77,11 @@ private void load(GameHost host, RealmAccess realm) } } }, - blockAction = new SettingsButton + blockAction = new SettingsButtonV2 { Text = @"Block realm", }, - unblockAction = new SettingsButton + unblockAction = new SettingsButtonV2 { Text = @"Unblock realm", } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs index 467c98802074..e4c64612f5a9 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs @@ -3,8 +3,10 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay @@ -18,19 +20,23 @@ private void load(OsuConfigManager config, OsuConfigManager osuConfig) { Children = new Drawable[] { - new SettingsSlider + new SettingsItemV2(new FormSliderBar { - LabelText = AudioSettingsStrings.PositionalLevel, - Keywords = new[] { @"positional", @"balance" }, + Caption = AudioSettingsStrings.PositionalLevel, Current = osuConfig.GetBindable(OsuSetting.PositionalHitsoundsLevel), KeyboardStep = 0.01f, DisplayAsPercentage = true + }) + { + Keywords = new[] { @"positional", @"balance" }, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - ClassicDefault = false, - LabelText = GameplaySettingsStrings.AlwaysPlayFirstComboBreak, + Caption = GameplaySettingsStrings.AlwaysPlayFirstComboBreak, Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak) + }) + { + ApplyClassicDefault = c => ((IHasCurrentValue)c).Current.Value = false, } }; } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs index 830ccec27926..23478fe95457 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay @@ -18,31 +19,33 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsSlider + new SettingsItemV2(new FormSliderBar { - LabelText = GameplaySettingsStrings.BackgroundDim, + Caption = GameplaySettingsStrings.BackgroundDim, Current = config.GetBindable(OsuSetting.DimLevel), KeyboardStep = 0.01f, DisplayAsPercentage = true - }, - new SettingsSlider + }), + new SettingsItemV2(new FormSliderBar { - LabelText = GameplaySettingsStrings.BackgroundBlur, + Caption = GameplaySettingsStrings.BackgroundBlur, Current = config.GetBindable(OsuSetting.BlurLevel), KeyboardStep = 0.01f, DisplayAsPercentage = true - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.LightenDuringBreaks, + Caption = GameplaySettingsStrings.LightenDuringBreaks, Current = config.GetBindable(OsuSetting.LightenDuringBreaks), + }) + { Keywords = new[] { "dim", "level" } }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.FadePlayfieldWhenHealthLow, + Caption = GameplaySettingsStrings.FadePlayfieldWhenHealthLow, Current = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow), - }, + }), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs index 69566d85f489..32e79809e5e6 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay @@ -23,35 +24,41 @@ private void load(OsuConfigManager config) Children = new Drawable[] { - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = SkinSettingsStrings.BeatmapSkins, + Caption = SkinSettingsStrings.BeatmapSkins, Current = config.GetBindable(OsuSetting.BeatmapSkins) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - Keywords = new[] { "combo", "override", "color" }, - LabelText = SkinSettingsStrings.BeatmapColours, + Caption = SkinSettingsStrings.BeatmapColours, Current = config.GetBindable(OsuSetting.BeatmapColours) + }) + { + Keywords = new[] { "combo", "override", "color" }, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - Keywords = new[] { "samples", "override" }, - LabelText = SkinSettingsStrings.BeatmapHitsounds, + Caption = SkinSettingsStrings.BeatmapHitsounds, Current = config.GetBindable(OsuSetting.BeatmapHitsounds) + }) + { + Keywords = new[] { "samples", "override" }, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = GraphicsSettingsStrings.StoryboardVideo, + Caption = GraphicsSettingsStrings.StoryboardVideo, Current = config.GetBindable(OsuSetting.ShowStoryboard) - }, - new SettingsSlider + }), + new SettingsItemV2(new FormSliderBar { - Keywords = new[] { "color" }, - LabelText = GraphicsSettingsStrings.ComboColourNormalisation, + Caption = GraphicsSettingsStrings.ComboColourNormalisation, Current = comboColourNormalisation, DisplayAsPercentage = true, - } + }) + { + Keywords = new[] { "color" }, + }, }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 779d5cdf005f..4f402c1cb7de 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -3,8 +3,10 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Rulesets.Scoring; @@ -19,23 +21,25 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - ClassicDefault = ScoringMode.Classic, - LabelText = GameplaySettingsStrings.ScoreDisplayMode, + Caption = GameplaySettingsStrings.ScoreDisplayMode, Current = config.GetBindable(OsuSetting.ScoreDisplayMode), - Keywords = new[] { "scoring" } + }) + { + Keywords = new[] { "scoring" }, + ApplyClassicDefault = c => ((IHasCurrentValue)c).Current.Value = ScoringMode.Classic, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = GraphicsSettingsStrings.HitLighting, + Caption = GraphicsSettingsStrings.HitLighting, Current = config.GetBindable(OsuSetting.HitLighting) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.StarFountains, + Caption = GameplaySettingsStrings.StarFountains, Current = config.GetBindable(OsuSetting.StarFountains) - }, + }), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs index b4caaf798378..711e10da47ae 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs @@ -3,8 +3,10 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay @@ -18,44 +20,50 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = GameplaySettingsStrings.HUDVisibilityMode, + Caption = GameplaySettingsStrings.HUDVisibilityMode, Current = config.GetBindable(OsuSetting.HUDVisibilityMode) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.ShowReplaySettingsOverlay, + Caption = GameplaySettingsStrings.ShowReplaySettingsOverlay, Current = config.GetBindable(OsuSetting.ReplaySettingsOverlay), + }) + { Keywords = new[] { "hide" }, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.AlwaysShowKeyOverlay, + Caption = GameplaySettingsStrings.AlwaysShowKeyOverlay, Current = config.GetBindable(OsuSetting.KeyOverlay), + }) + { Keywords = new[] { "counter" }, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.AlwaysShowGameplayLeaderboard, + Caption = GameplaySettingsStrings.AlwaysShowGameplayLeaderboard, Current = config.GetBindable(OsuSetting.GameplayLeaderboard), - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.AlwaysRequireHoldForMenu, + Caption = GameplaySettingsStrings.AlwaysRequireHoldForMenu, Current = config.GetBindable(OsuSetting.AlwaysRequireHoldingForPause), - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.AlwaysShowHoldForMenuButton, + Caption = GameplaySettingsStrings.AlwaysShowHoldForMenuButton, Current = config.GetBindable(OsuSetting.AlwaysShowHoldForMenuButton), - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - ClassicDefault = false, - LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, + Caption = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), - Keywords = new[] { "hp", "bar" } + }) + { + Keywords = new[] { "hp", "bar" }, + ApplyClassicDefault = c => ((IHasCurrentValue)c).Current.Value = false, }, }; } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs index c245a1a9ea5e..bed050fac50a 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay @@ -19,32 +20,35 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsSlider> + new SettingsItemV2(new FormSliderBar { - LabelText = SkinSettingsStrings.GameplayCursorSize, + Caption = SkinSettingsStrings.GameplayCursorSize, Current = config.GetBindable(OsuSetting.GameplayCursorSize), - KeyboardStep = 0.01f - }, - new SettingsCheckbox + KeyboardStep = 0.01f, + LabelFormat = v => $"{v:0.##}x" + }), + new SettingsItemV2(new FormCheckBox { - LabelText = SkinSettingsStrings.AutoCursorSize, + Caption = SkinSettingsStrings.AutoCursorSize, Current = config.GetBindable(OsuSetting.AutoCursorSize) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = SkinSettingsStrings.GameplayCursorDuringTouch, - Keywords = new[] { @"touchscreen" }, + Caption = SkinSettingsStrings.GameplayCursorDuringTouch, Current = config.GetBindable(OsuSetting.GameplayCursorDuringTouch) + }) + { + Keywords = new[] { @"touchscreen" }, }, }; if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) { - Add(new SettingsCheckbox + Add(new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.DisableWinKey, + Caption = GameplaySettingsStrings.DisableWinKey, Current = config.GetBindable(OsuSetting.GameplayDisableWinKey) - }); + })); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs index 79a971510fd7..136108e0abc7 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay @@ -21,10 +22,12 @@ private void load(OsuConfigManager config) { Children = new[] { - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.IncreaseFirstObjectVisibility, + Caption = GameplaySettingsStrings.IncreaseFirstObjectVisibility, Current = config.GetBindable(OsuSetting.IncreaseFirstObjectVisibility), + }) + { Keywords = new[] { @"approach", @"circle", @"hidden" }, }, }; diff --git a/osu.Game/Overlays/Settings/Sections/General/InstallationSettings.cs b/osu.Game/Overlays/Settings/Sections/General/InstallationSettings.cs new file mode 100644 index 000000000000..99d04398dfda --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/General/InstallationSettings.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Game.Localisation; +using osu.Game.Overlays.Settings.Sections.Maintenance; + +namespace osu.Game.Overlays.Settings.Sections.General +{ + public partial class InstallationSettings : SettingsSubsection + { + protected override LocalisableString Header => GeneralSettingsStrings.InstallationHeader; + + [Resolved] + private OsuGame? game { get; set; } + + [BackgroundDependencyLoader] + private void load(Storage storage) + { + Add(new SettingsButtonV2 + { + Text = GeneralSettingsStrings.OpenOsuFolder, + Keywords = new[] { @"logs", @"files", @"access", "directory" }, + Action = () => storage.PresentExternally(), + }); + + Add(new DangerousSettingsButtonV2 + { + Text = GeneralSettingsStrings.ChangeFolderLocation, + Action = () => game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())) + }); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index 2af6e36b7fe0..515302239b2a 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.General @@ -19,22 +20,22 @@ private void load(OsuGameBase game, OsuConfigManager config, FrameworkConfigMana { Children = new Drawable[] { - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = GeneralSettingsStrings.LanguageDropdown, + Caption = GeneralSettingsStrings.LanguageDropdown, Current = game.CurrentLanguage, AlwaysShowSearchBar = true, - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = GeneralSettingsStrings.PreferOriginalMetadataLanguage, + Caption = GeneralSettingsStrings.PreferOriginalMetadataLanguage, Current = frameworkConfig.GetBindable(FrameworkSetting.ShowUnicode) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = GeneralSettingsStrings.Prefer24HourTimeDisplay, + Caption = GeneralSettingsStrings.Prefer24HourTimeDisplay, Current = config.GetBindable(OsuSetting.Prefer24HourTime) - }, + }), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/General/QuickActionSettings.cs b/osu.Game/Overlays/Settings/Sections/General/QuickActionSettings.cs new file mode 100644 index 000000000000..f6fe576dc227 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/General/QuickActionSettings.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Statistics; +using osu.Game.Graphics; +using osu.Game.IO; +using osu.Game.Localisation; +using osu.Game.Online.Chat; +using osu.Game.Overlays.Notifications; +using osu.Game.Utils; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Writers.Zip; + +namespace osu.Game.Overlays.Settings.Sections.General +{ + public partial class QuickActionSettings : SettingsSubsection + { + [Resolved(CanBeNull = true)] + private FirstRunSetupOverlay? firstRunSetupOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private OsuGame? game { get; set; } + + protected override LocalisableString Header => GeneralSettingsStrings.QuickActionsHeader; + + [BackgroundDependencyLoader] + private void load(OsuColour colours, Storage storage) + { + AddRange(new Drawable[] + { + new SettingsButtonV2 + { + Text = GeneralSettingsStrings.RunSetupWizard, + Keywords = new[] { @"first run", @"initial", @"getting started", @"import", @"tutorial", @"recommended beatmaps" }, + TooltipText = FirstRunSetupOverlayStrings.FirstRunSetupDescription, + Action = () => firstRunSetupOverlay?.Show(), + }, + new SettingsButtonV2 + { + Text = GeneralSettingsStrings.LearnMoreAboutLazer, + TooltipText = GeneralSettingsStrings.LearnMoreAboutLazerTooltip, + BackgroundColour = colours.YellowDark, + Action = () => game?.ShowWiki(@"Help_centre/Upgrading_to_lazer") + }, + new SettingsButtonV2 + { + Text = GeneralSettingsStrings.ReportIssue, + TooltipText = GeneralSettingsStrings.ReportIssueTooltip, + BackgroundColour = colours.YellowDarker, + Action = () => game?.OpenUrlExternally(@"https://osu.ppy.sh/community/forums/topics/create?forum_id=5", LinkWarnMode.NeverWarn) + }, + }); + + Add(new SettingsButtonV2 + { + Text = GeneralSettingsStrings.ExportLogs, + BackgroundColour = colours.YellowDarker.Darken(0.5f), + Keywords = new[] { @"bug", "report", "logs", "files" }, + Action = () => Task.Run(exportLogs), + }); + + exportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports"); + } + + [Resolved] + private INotificationOverlay? notifications { get; set; } + + private Storage exportStorage = null!; + + private void exportLogs() + { + ProgressNotification notification = new ProgressNotification + { + State = ProgressNotificationState.Active, + Text = NotificationsStrings.LogsExportOngoing, + }; + + notifications?.Post(notification); + + const string archive_filename = "compressed-logs.zip"; + + try + { + GlobalStatistics.OutputToLog(); + Logger.Flush(); + + var logStorage = Logger.Storage; + + using (var outStream = exportStorage.CreateFileSafely(archive_filename)) + using (var zip = ZipArchive.CreateArchive()) + { + foreach (string? f in logStorage.GetFiles(string.Empty, "*.log")) + FileUtils.AttemptOperation(z => z.AddEntry(f, logStorage.GetStream(f), closeStream: true), zip, throwOnFailure: false); + + zip.SaveTo(outStream, new ZipWriterOptions(CompressionType.Deflate)); + } + } + catch + { + notification.State = ProgressNotificationState.Cancelled; + + // cleanup if export is failed or canceled. + exportStorage.Delete(archive_filename); + throw; + } + + notification.CompletionText = NotificationsStrings.LogsExportFinished; + notification.CompletionClickAction = () => exportStorage.PresentFileExternally(archive_filename); + + notification.State = ProgressNotificationState.Completed; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 596e4b2589f9..c8efb50accf2 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -7,20 +7,13 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Framework.Screens; -using osu.Framework.Statistics; using osu.Game.Configuration; -using osu.Game.IO; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Online.Multiplayer; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Notifications; -using osu.Game.Overlays.Settings.Sections.Maintenance; using osu.Game.Updater; -using osu.Game.Utils; -using SharpCompress.Archives.Zip; namespace osu.Game.Overlays.Settings.Sections.General { @@ -28,8 +21,10 @@ public partial class UpdateSettings : SettingsSubsection { protected override LocalisableString Header => GeneralSettingsStrings.UpdateHeader; - private SettingsButton checkForUpdatesButton = null!; - private SettingsEnumDropdown releaseStreamDropdown = null!; + private SettingsButtonV2 checkForUpdatesButton = null!; + private FormEnumDropdown releaseStreamDropdown = null!; + + private readonly Bindable releaseStreamDropdownNote = new Bindable(); private readonly Bindable configReleaseStream = new Bindable(); @@ -45,79 +40,42 @@ public partial class UpdateSettings : SettingsSubsection [Resolved] private IDialogOverlay? dialogOverlay { get; set; } - private Storage exportStorage = null!; - [BackgroundDependencyLoader] - private void load(OsuConfigManager config, Storage storage) + private void load(OsuConfigManager config) { config.BindWith(OsuSetting.ReleaseStream, configReleaseStream); bool isDesktop = RuntimeInfo.IsDesktop; - bool supportsExport = RuntimeInfo.OS != RuntimeInfo.Platform.Android; - bool canCheckUpdates = updateManager?.CanCheckForUpdate == true; - if (canCheckUpdates) + // For simplicity, hide the concept of release streams from mobile users. + if (isDesktop) { - // For simplicity, hide the concept of release streams from mobile users. - if (isDesktop) + Add(new SettingsItemV2(releaseStreamDropdown = new FormEnumDropdown { - Add(releaseStreamDropdown = new SettingsEnumDropdown - { - LabelText = GeneralSettingsStrings.ReleaseStream, - Current = { Value = configReleaseStream.Value }, - Keywords = new[] { @"version" }, - }); - - if (updateManager!.FixedReleaseStream != null) - { - configReleaseStream.Value = updateManager.FixedReleaseStream.Value; - - releaseStreamDropdown.ShowsDefaultIndicator = false; - releaseStreamDropdown.Items = [updateManager.FixedReleaseStream.Value]; - releaseStreamDropdown.SetNoticeText(GeneralSettingsStrings.ChangeReleaseStreamPackageManagerWarning); - } - - releaseStreamDropdown.Current.BindValueChanged(releaseStreamChanged); - } - - Add(checkForUpdatesButton = new SettingsButton + Caption = GeneralSettingsStrings.ReleaseStream, + Current = { Value = configReleaseStream.Value }, + }) { - Text = GeneralSettingsStrings.CheckUpdate, - Action = () => checkForUpdates().FireAndForget() + Keywords = new[] { @"version" }, + ShowRevertToDefaultButton = updateManager!.FixedReleaseStream == null }); - } - // Loosely update-related maintenance buttons. - if (isDesktop) - { - Add(new SettingsButton + if (updateManager!.FixedReleaseStream != null) { - Text = GeneralSettingsStrings.OpenOsuFolder, - Keywords = new[] { @"logs", @"files", @"access", "directory" }, - Action = () => storage.PresentExternally(), - }); - } + configReleaseStream.Value = updateManager.FixedReleaseStream.Value; - if (supportsExport) - { - Add(new SettingsButton - { - Text = GeneralSettingsStrings.ExportLogs, - Keywords = new[] { @"bug", "report", "logs", "files" }, - Action = () => Task.Run(exportLogs), - }); - } + releaseStreamDropdown.Items = [updateManager.FixedReleaseStream.Value]; + releaseStreamDropdownNote.Value = new SettingsNote.Data(GeneralSettingsStrings.ChangeReleaseStreamPackageManagerWarning, SettingsNote.Type.Warning); + } - if (isDesktop) - { - Add(new SettingsButton - { - Text = GeneralSettingsStrings.ChangeFolderLocation, - Action = () => game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())) - }); + releaseStreamDropdown.Current.BindValueChanged(releaseStreamChanged); } - exportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports"); + Add(checkForUpdatesButton = new SettingsButtonV2 + { + Text = GeneralSettingsStrings.CheckUpdate, + Action = () => checkForUpdates().FireAndForget() + }); } private void releaseStreamChanged(ValueChangedEvent stream) @@ -176,48 +134,5 @@ private async Task checkForUpdates() checkForUpdatesButton.Enabled.Value = true; } } - - private void exportLogs() - { - ProgressNotification notification = new ProgressNotification - { - State = ProgressNotificationState.Active, - Text = "Exporting logs...", - }; - - notifications?.Post(notification); - - const string archive_filename = "compressed-logs.zip"; - - try - { - GlobalStatistics.OutputToLog(); - Logger.Flush(); - - var logStorage = Logger.Storage; - - using (var outStream = exportStorage.CreateFileSafely(archive_filename)) - using (var zip = ZipArchive.Create()) - { - foreach (string? f in logStorage.GetFiles(string.Empty, "*.log")) - FileUtils.AttemptOperation(z => z.AddEntry(f, logStorage.GetStream(f), true), zip); - - zip.SaveTo(outStream); - } - } - catch - { - notification.State = ProgressNotificationState.Cancelled; - - // cleanup if export is failed or canceled. - exportStorage.Delete(archive_filename); - throw; - } - - notification.CompletionText = "Exported logs! Click to view."; - notification.CompletionClickAction = () => exportStorage.PresentFileExternally(archive_filename); - - notification.State = ProgressNotificationState.Completed; - } } } diff --git a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs index 2aa1008b1da6..7136de13276e 100644 --- a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -8,17 +9,12 @@ using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.General; +using osu.Game.Updater; namespace osu.Game.Overlays.Settings.Sections { public partial class GeneralSection : SettingsSection { - [Resolved(CanBeNull = true)] - private FirstRunSetupOverlay? firstRunSetupOverlay { get; set; } - - [Resolved(CanBeNull = true)] - private OsuGame? game { get; set; } - public override LocalisableString Header => CommonStrings.General; public override Drawable CreateIcon() => new SpriteIcon @@ -27,27 +23,14 @@ public partial class GeneralSection : SettingsSection }; [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(UpdateManager? updateManager) { - Children = new Drawable[] - { - new SettingsButton - { - Text = GeneralSettingsStrings.RunSetupWizard, - Keywords = new[] { @"first run", @"initial", @"getting started", @"import", @"tutorial", @"recommended beatmaps" }, - TooltipText = FirstRunSetupOverlayStrings.FirstRunSetupDescription, - Action = () => firstRunSetupOverlay?.Show(), - }, - new SettingsButton - { - Text = GeneralSettingsStrings.LearnMoreAboutLazer, - TooltipText = GeneralSettingsStrings.LearnMoreAboutLazerTooltip, - BackgroundColour = colours.YellowDark, - Action = () => game?.ShowWiki(@"Help_centre/Upgrading_to_lazer") - }, - new LanguageSettings(), - new UpdateSettings(), - }; + Add(new QuickActionSettings()); + Add(new LanguageSettings()); + if (updateManager?.CanCheckForUpdate == true) + Add(new UpdateSettings()); + if (RuntimeInfo.IsDesktop) + Add(new InstallationSettings()); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index f40a4c941fa2..cc1105c57921 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -9,7 +9,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -18,7 +17,7 @@ using osu.Framework.Platform.Windows; using osu.Game.Configuration; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osuTK; using osuTK.Graphics; @@ -29,15 +28,17 @@ public partial class LayoutSettings : SettingsSubsection { protected override LocalisableString Header => GraphicsSettingsStrings.LayoutHeader; - private FillFlowContainer> scalingSettings = null!; - private SettingsSlider dimSlider = null!; + private FillFlowContainer scalingSettings = null!; private readonly Bindable currentDisplay = new Bindable(); private Bindable scalingMode = null!; private Bindable sizeFullscreen = null!; + private Bindable sizeWindowed = null!; - private readonly BindableList resolutions = new BindableList(new[] { new Size(9999, 9999) }); + private readonly BindableList resolutionsFullscreen = new BindableList(new[] { new Size(9999, 9999) }); + private readonly BindableList resolutionsWindowed = new BindableList(); + private readonly Bindable windowedResolution = new Bindable(); private readonly IBindable fullscreenCapability = new Bindable(FullscreenCapability.Capable); [Resolved] @@ -48,12 +49,22 @@ public partial class LayoutSettings : SettingsSubsection private IWindow? window; - private SettingsDropdown resolutionDropdown = null!; - private SettingsDropdown displayDropdown = null!; - private SettingsDropdown windowModeDropdown = null!; - private SettingsCheckbox minimiseOnFocusLossCheckbox = null!; - private SettingsCheckbox safeAreaConsiderationsCheckbox = null!; + private readonly BindableBool resolutionFullscreenCanBeShown = new BindableBool(true); + private readonly BindableBool resolutionWindowedCanBeShown = new BindableBool(true); + private readonly BindableBool displayDropdownCanBeShown = new BindableBool(true); + private readonly BindableBool minimiseOnFocusLossCanBeShown = new BindableBool(true); + private readonly BindableBool safeAreaConsiderationsCanBeShown = new BindableBool(true); + private FormDropdown resolutionWindowedDropdown = null!; + private FormDropdown displayDropdown = null!; + private FormDropdown windowModeDropdown = null!; + + private FormSliderBar dimSlider = null!; + + private readonly Bindable windowModeDropdownNote = new Bindable(); + + private Bindable windowedPositionX = null!; + private Bindable windowedPositionY = null!; private Bindable scalingPositionX = null!; private Bindable scalingPositionY = null!; private Bindable scalingSizeX = null!; @@ -70,12 +81,17 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, Gam scalingMode = osuConfig.GetBindable(OsuSetting.Scaling); sizeFullscreen = config.GetBindable(FrameworkSetting.SizeFullscreen); + sizeWindowed = config.GetBindable(FrameworkSetting.WindowedSize); + windowedPositionX = config.GetBindable(FrameworkSetting.WindowedPositionX); + windowedPositionY = config.GetBindable(FrameworkSetting.WindowedPositionY); scalingSizeX = osuConfig.GetBindable(OsuSetting.ScalingSizeX); scalingSizeY = osuConfig.GetBindable(OsuSetting.ScalingSizeY); scalingPositionX = osuConfig.GetBindable(OsuSetting.ScalingPositionX); scalingPositionY = osuConfig.GetBindable(OsuSetting.ScalingPositionY); scalingBackgroundDim = osuConfig.GetBindable(OsuSetting.ScalingBackgroundDim); + windowedResolution.Value = sizeWindowed.Value; + if (window != null) { currentDisplay.BindTo(window.CurrentDisplayBindable); @@ -87,98 +103,137 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, Gam Children = new Drawable[] { - windowModeDropdown = new SettingsDropdown + new SettingsItemV2(windowModeDropdown = new FormDropdown { - LabelText = GraphicsSettingsStrings.ScreenMode, + Caption = GraphicsSettingsStrings.ScreenMode, Items = window?.SupportedWindowModes, - CanBeShown = { Value = window?.SupportedWindowModes.Count() > 1 }, Current = config.GetBindable(FrameworkSetting.WindowMode), + }) + { + CanBeShown = { Value = window?.SupportedWindowModes.Count() > 1 }, + Note = { BindTarget = windowModeDropdownNote }, }, - displayDropdown = new DisplaySettingsDropdown + new SettingsItemV2(displayDropdown = new DisplayDropdown { - LabelText = GraphicsSettingsStrings.Display, + Caption = GraphicsSettingsStrings.Display, Items = window?.Displays, Current = currentDisplay, + }) + { + CanBeShown = { BindTarget = displayDropdownCanBeShown } }, - resolutionDropdown = new ResolutionSettingsDropdown + new SettingsItemV2(new ResolutionDropdown { - LabelText = GraphicsSettingsStrings.Resolution, - ShowsDefaultIndicator = false, - ItemSource = resolutions, + Caption = GraphicsSettingsStrings.Resolution, + ItemSource = resolutionsFullscreen, Current = sizeFullscreen + }) + { + CanBeShown = { BindTarget = resolutionFullscreenCanBeShown }, + ShowRevertToDefaultButton = false, }, - minimiseOnFocusLossCheckbox = new SettingsCheckbox + new SettingsItemV2(resolutionWindowedDropdown = new ResolutionDropdown { - LabelText = GraphicsSettingsStrings.MinimiseOnFocusLoss, + Caption = GraphicsSettingsStrings.Resolution, + ItemSource = resolutionsWindowed, + Current = windowedResolution + }) + { + CanBeShown = { BindTarget = resolutionWindowedCanBeShown }, + ShowRevertToDefaultButton = false, + }, + new SettingsItemV2(new FormCheckBox + { + Caption = GraphicsSettingsStrings.MinimiseOnFocusLoss, Current = config.GetBindable(FrameworkSetting.MinimiseOnFocusLossInFullscreen), + }) + { + CanBeShown = { BindTarget = minimiseOnFocusLossCanBeShown }, Keywords = new[] { "alt-tab", "minimize", "focus", "hide" }, }, - safeAreaConsiderationsCheckbox = new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = GraphicsSettingsStrings.ShrinkGameToSafeArea, + Caption = GraphicsSettingsStrings.ShrinkGameToSafeArea, Current = osuConfig.GetBindable(OsuSetting.SafeAreaConsiderations), + }) + { + CanBeShown = { BindTarget = safeAreaConsiderationsCanBeShown }, }, - new SettingsSlider + new SettingsItemV2(new FormSliderBar { - LabelText = GraphicsSettingsStrings.UIScaling, + Caption = GraphicsSettingsStrings.UIScaling, TransferValueOnCommit = true, Current = osuConfig.GetBindable(OsuSetting.UIScale), KeyboardStep = 0.01f, + LabelFormat = v => $@"{v:0.##}x", + }) + { Keywords = new[] { "scale", "letterbox" }, }, - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = GraphicsSettingsStrings.ScreenScaling, + Caption = GraphicsSettingsStrings.ScreenScaling, Current = osuConfig.GetBindable(OsuSetting.Scaling), + }) + { Keywords = new[] { "scale", "letterbox" }, }, - scalingSettings = new FillFlowContainer> + scalingSettings = new FillFlowContainer { Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Masking = true, + Spacing = new Vector2(0, SettingsSection.ITEM_SPACING_V2), Children = new[] { - new SettingsSlider + new SettingsItemV2(new FormSliderBar { - LabelText = GraphicsSettingsStrings.HorizontalPosition, - Keywords = new[] { "screen", "scaling" }, + Caption = GraphicsSettingsStrings.HorizontalPosition, Current = scalingPositionX, KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsSlider + DisplayAsPercentage = true, + }.With(bindPreviewEvent)) { - LabelText = GraphicsSettingsStrings.VerticalPosition, Keywords = new[] { "screen", "scaling" }, + }, + new SettingsItemV2(new FormSliderBar + { + Caption = GraphicsSettingsStrings.VerticalPosition, Current = scalingPositionY, KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsSlider + DisplayAsPercentage = true, + }.With(bindPreviewEvent)) { - LabelText = GraphicsSettingsStrings.HorizontalScale, Keywords = new[] { "screen", "scaling" }, + }, + new SettingsItemV2(new FormSliderBar + { + Caption = GraphicsSettingsStrings.HorizontalScale, Current = scalingSizeX, KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsSlider + DisplayAsPercentage = true, + }.With(bindPreviewEvent)) { - LabelText = GraphicsSettingsStrings.VerticalScale, Keywords = new[] { "screen", "scaling" }, + }, + new SettingsItemV2(new FormSliderBar + { + Caption = GraphicsSettingsStrings.VerticalScale, Current = scalingSizeY, KeyboardStep = 0.01f, - DisplayAsPercentage = true + DisplayAsPercentage = true, + }.With(bindPreviewEvent)) + { + Keywords = new[] { "screen", "scaling" }, }, - dimSlider = new SettingsSlider + new SettingsItemV2(dimSlider = new FormSliderBar { - LabelText = GameplaySettingsStrings.BackgroundDim, + Caption = GameplaySettingsStrings.BackgroundDim, Current = scalingBackgroundDim, KeyboardStep = 0.01f, DisplayAsPercentage = true, - }, + }.With(bindPreviewEvent)), } }, }; @@ -190,8 +245,6 @@ protected override void LoadComplete() { base.LoadComplete(); - scalingSettings.ForEach(s => bindPreviewEvent(s.Current)); - windowModeDropdown.Current.BindValueChanged(_ => { updateDisplaySettingsVisibility(); @@ -202,19 +255,68 @@ protected override void LoadComplete() { if (display.NewValue == null) { - resolutions.Clear(); + resolutionsFullscreen.Clear(); + resolutionsWindowed.Clear(); return; } - resolutions.ReplaceRange(1, resolutions.Count - 1, display.NewValue.DisplayModes - .Where(m => m.Size.Width >= 800 && m.Size.Height >= 600) - .OrderByDescending(m => Math.Max(m.Size.Height, m.Size.Width)) - .Select(m => m.Size) - .Distinct()); + var buffer = new Bindable(windowedResolution.Value); + resolutionWindowedDropdown.Current = buffer; + + var fullscreenResolutions = display.NewValue.DisplayModes + .Where(m => m.Size.Width >= 800 && m.Size.Height >= 600) + .OrderByDescending(m => Math.Max(m.Size.Height, m.Size.Width)) + .Select(m => m.Size) + .Distinct() + .ToList(); + var windowedResolutions = fullscreenResolutions + .Where(res => res.Width <= display.NewValue.UsableBounds.Width && res.Height <= display.NewValue.UsableBounds.Height) + .ToList(); + + resolutionsFullscreen.ReplaceRange(1, resolutionsFullscreen.Count - 1, fullscreenResolutions); + resolutionsWindowed.ReplaceRange(0, resolutionsWindowed.Count, windowedResolutions); + + resolutionWindowedDropdown.Current = windowedResolution; updateDisplaySettingsVisibility(); }), true); + windowedResolution.BindValueChanged(size => + { + if (size.NewValue == sizeWindowed.Value || windowModeDropdown.Current.Value != WindowMode.Windowed) + return; + + if (window?.WindowState == Framework.Platform.WindowState.Maximised) + { + window.WindowState = Framework.Platform.WindowState.Normal; + } + + // Adjust only for top decorations (assuming system titlebar). + // Bottom/left/right borders are ignored as invisible padding, which don't align with the screen. + var dBounds = currentDisplay.Value.Bounds; + var dUsable = currentDisplay.Value.UsableBounds; + float topBar = host.Window?.BorderSize.Value.Top ?? 0; + + int w = Math.Min(size.NewValue.Width, dUsable.Width); + int h = (int)Math.Min(size.NewValue.Height, dUsable.Height - topBar); + + windowedResolution.Value = new Size(w, h); + sizeWindowed.Value = windowedResolution.Value; + + float adjustedY = Math.Max( + dUsable.Y + (dUsable.Height - h) / 2f, + dUsable.Y + topBar // titlebar adjustment + ); + windowedPositionY.Value = dBounds.Height - h != 0 ? (adjustedY - dBounds.Y) / (dBounds.Height - h) : 0; + windowedPositionX.Value = dBounds.Width - w != 0 ? (dUsable.X - dBounds.X + (dUsable.Width - w) / 2f) / (dBounds.Width - w) : 0; + }); + + sizeWindowed.BindValueChanged(size => + { + if (size.NewValue != windowedResolution.Value) + windowedResolution.Value = size.NewValue; + }); + scalingMode.BindValueChanged(_ => { scalingSettings.ClearTransforms(); @@ -223,8 +325,6 @@ protected override void LoadComplete() updateScalingModeVisibility(); }); - - // initial update bypasses transforms updateScalingModeVisibility(); void updateScalingModeVisibility() @@ -233,18 +333,19 @@ void updateScalingModeVisibility() scalingSettings.ResizeHeightTo(0, transition_duration, Easing.OutQuint); scalingSettings.AutoSizeAxes = scalingMode.Value != ScalingMode.Off ? Axes.Y : Axes.None; - scalingSettings.ForEach(s => + + foreach (SettingsItemV2 item in scalingSettings) { - if (s == dimSlider) - { - s.CanBeShown.Value = scalingMode.Value == ScalingMode.Everything || scalingMode.Value == ScalingMode.ExcludeOverlays; - } + FormSliderBar slider = (FormSliderBar)item.Control; + + if (slider == dimSlider) + item.CanBeShown.Value = scalingMode.Value == ScalingMode.Everything || scalingMode.Value == ScalingMode.ExcludeOverlays; else { - s.TransferValueOnCommit = scalingMode.Value == ScalingMode.Everything; - s.CanBeShown.Value = scalingMode.Value != ScalingMode.Off; + slider.TransferValueOnCommit = scalingMode.Value == ScalingMode.Everything; + item.CanBeShown.Value = scalingMode.Value != ScalingMode.Off; } - }); + } } } @@ -260,10 +361,12 @@ private void onDisplaysChanged(IEnumerable displays) private void updateDisplaySettingsVisibility() { - resolutionDropdown.CanBeShown.Value = resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen; - displayDropdown.CanBeShown.Value = displayDropdown.Items.Count() > 1; - minimiseOnFocusLossCheckbox.CanBeShown.Value = RuntimeInfo.IsDesktop && windowModeDropdown.Current.Value == WindowMode.Fullscreen; - safeAreaConsiderationsCheckbox.CanBeShown.Value = host.Window?.SafeAreaPadding.Value.Total != Vector2.Zero; + resolutionFullscreenCanBeShown.Value = windowModeDropdown.Current.Value == WindowMode.Fullscreen && resolutionsFullscreen.Count > 1; + resolutionWindowedCanBeShown.Value = windowModeDropdown.Current.Value == WindowMode.Windowed && resolutionsWindowed.Count > 1; + + displayDropdownCanBeShown.Value = displayDropdown.Items.Count() > 1; + minimiseOnFocusLossCanBeShown.Value = RuntimeInfo.IsDesktop && windowModeDropdown.Current.Value == WindowMode.Fullscreen; + safeAreaConsiderationsCanBeShown.Value = host.Window?.SafeAreaPadding.Value.Total != Vector2.Zero; } private void updateScreenModeWarning() @@ -272,16 +375,16 @@ private void updateScreenModeWarning() if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && !FrameworkEnvironment.UseSDL3) { if (windowModeDropdown.Current.Value == WindowMode.Fullscreen) - windowModeDropdown.SetNoticeText(LayoutSettingsStrings.FullscreenMacOSNote, true); + windowModeDropdownNote.Value = new SettingsNote.Data(LayoutSettingsStrings.FullscreenMacOSNote, SettingsNote.Type.Critical); else - windowModeDropdown.ClearNoticeText(); + windowModeDropdownNote.Value = null; return; } if (windowModeDropdown.Current.Value != WindowMode.Fullscreen) { - windowModeDropdown.SetNoticeText(GraphicsSettingsStrings.NotFullscreenNote, true); + windowModeDropdownNote.Value = new SettingsNote.Data(GraphicsSettingsStrings.NotFullscreenNote, SettingsNote.Type.Warning); return; } @@ -290,28 +393,28 @@ private void updateScreenModeWarning() switch (fullscreenCapability.Value) { case FullscreenCapability.Unknown: - windowModeDropdown.SetNoticeText(LayoutSettingsStrings.CheckingForFullscreenCapabilities, true); + windowModeDropdownNote.Value = new SettingsNote.Data(LayoutSettingsStrings.CheckingForFullscreenCapabilities, SettingsNote.Type.Informational); break; case FullscreenCapability.Capable: - windowModeDropdown.SetNoticeText(LayoutSettingsStrings.OsuIsRunningExclusiveFullscreen); + windowModeDropdownNote.Value = new SettingsNote.Data(LayoutSettingsStrings.OsuIsRunningExclusiveFullscreen, SettingsNote.Type.Informational); break; case FullscreenCapability.Incapable: - windowModeDropdown.SetNoticeText(LayoutSettingsStrings.UnableToRunExclusiveFullscreen, true); + windowModeDropdownNote.Value = new SettingsNote.Data(LayoutSettingsStrings.UnableToRunExclusiveFullscreen, SettingsNote.Type.Warning); break; } } else { // We can only detect exclusive fullscreen status on windows currently. - windowModeDropdown.ClearNoticeText(); + windowModeDropdownNote.Value = null; } } - private void bindPreviewEvent(Bindable bindable) + private void bindPreviewEvent(FormSliderBar slider) { - bindable.ValueChanged += _ => + slider.Current.ValueChanged += _ => { switch (scalingMode.Value) { @@ -354,37 +457,22 @@ public ScalingPreview() } } - private partial class UIScaleSlider : RoundedSliderBar - { - public override LocalisableString TooltipText => base.TooltipText + "x"; - } - - private partial class DisplaySettingsDropdown : SettingsDropdown + private partial class DisplayDropdown : FormDropdown { - protected override OsuDropdown CreateDropdown() => new DisplaySettingsDropdownControl(); - - private partial class DisplaySettingsDropdownControl : DropdownControl + protected override LocalisableString GenerateItemText(Display item) { - protected override LocalisableString GenerateItemText(Display item) - { - return $"{item.Index}: {item.Name} ({item.Bounds.Width}x{item.Bounds.Height})"; - } + return $"{item.Index}: {item.Name} ({item.Bounds.Width}x{item.Bounds.Height})"; } } - private partial class ResolutionSettingsDropdown : SettingsDropdown + private partial class ResolutionDropdown : FormDropdown { - protected override OsuDropdown CreateDropdown() => new ResolutionDropdownControl(); - - private partial class ResolutionDropdownControl : DropdownControl + protected override LocalisableString GenerateItemText(Size item) { - protected override LocalisableString GenerateItemText(Size item) - { - if (item == new Size(9999, 9999)) - return CommonStrings.Default; + if (item == new Size(9999, 9999)) + return CommonStrings.Default; - return $"{item.Width}x{item.Height}"; - } + return $"{item.Width}x{item.Height}"; } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index a8b127d5229a..f1cec99f38a0 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -9,7 +9,7 @@ using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays.Dialog; @@ -29,32 +29,39 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, IDi Children = new Drawable[] { - new RendererSettingsDropdown + new SettingsItemV2(new RendererDropdown { - LabelText = GraphicsSettingsStrings.Renderer, + Caption = GraphicsSettingsStrings.Renderer, Current = renderer, Items = host.GetPreferredRenderersForCurrentPlatform().Order() #pragma warning disable CS0612 // Type or member is obsolete .Where(t => t != RendererType.Vulkan && t != RendererType.OpenGLLegacy), #pragma warning restore CS0612 // Type or member is obsolete + }) + { Keywords = new[] { @"compatibility", @"directx" }, }, // TODO: this needs to be a custom dropdown at some point - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = GraphicsSettingsStrings.FrameLimiter, + Caption = GraphicsSettingsStrings.FrameLimiter, Current = config.GetBindable(FrameworkSetting.FrameSync), - Keywords = new[] { @"fps" }, + }) + { + Keywords = new[] { @"fps", @"framerate" }, }, - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = GraphicsSettingsStrings.ThreadingMode, + Caption = GraphicsSettingsStrings.ThreadingMode, Current = config.GetBindable(FrameworkSetting.ExecutionMode) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = GraphicsSettingsStrings.ShowFPS, - Current = osuConfig.GetBindable(OsuSetting.ShowFpsDisplay) + Caption = GraphicsSettingsStrings.ShowFPS, + Current = osuConfig.GetBindable(OsuSetting.ShowFpsDisplay), + }) + { + Keywords = new[] { @"framerate", @"counter" }, }, }; @@ -81,30 +88,25 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, IDi }); } - private partial class RendererSettingsDropdown : SettingsEnumDropdown + private partial class RendererDropdown : FormEnumDropdown { - protected override OsuDropdown CreateDropdown() => new RendererDropdown(); + private RendererType hostResolvedRenderer; + private bool automaticRendererInUse; - protected partial class RendererDropdown : DropdownControl + [BackgroundDependencyLoader] + private void load(FrameworkConfigManager config, GameHost host) { - private RendererType hostResolvedRenderer; - private bool automaticRendererInUse; - - [BackgroundDependencyLoader] - private void load(FrameworkConfigManager config, GameHost host) - { - var renderer = config.GetBindable(FrameworkSetting.Renderer); - automaticRendererInUse = renderer.Value == RendererType.Automatic; - hostResolvedRenderer = host.ResolvedRenderer; - } + var renderer = config.GetBindable(FrameworkSetting.Renderer); + automaticRendererInUse = renderer.Value == RendererType.Automatic; + hostResolvedRenderer = host.ResolvedRenderer; + } - protected override LocalisableString GenerateItemText(RendererType item) - { - if (item == RendererType.Automatic && automaticRendererInUse) - return LocalisableString.Interpolate($"{base.GenerateItemText(item)} ({hostResolvedRenderer.GetDescription()})"); + protected override LocalisableString GenerateItemText(RendererType item) + { + if (item == RendererType.Automatic && automaticRendererInUse) + return LocalisableString.Interpolate($"{base.GenerateItemText(item)} ({hostResolvedRenderer.GetDescription()})"); - return base.GenerateItemText(item); - } + return base.GenerateItemText(item); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs index c7180ec51bb9..f5dd19dc9904 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Graphics @@ -18,16 +19,16 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = GraphicsSettingsStrings.ScreenshotFormat, + Caption = GraphicsSettingsStrings.ScreenshotFormat, Current = config.GetBindable(OsuSetting.ScreenshotFormat) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = GraphicsSettingsStrings.ShowCursorInScreenshots, + Caption = GraphicsSettingsStrings.ShowCursorInScreenshots, Current = config.GetBindable(OsuSetting.ScreenshotCaptureMenuCursor) - } + }) }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/VideoSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/VideoSettings.cs index 2e0bbe3c162b..0481b450377b 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/VideoSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/VideoSettings.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Video; using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Graphics @@ -18,7 +19,7 @@ public partial class VideoSettings : SettingsSubsection protected override LocalisableString Header => GraphicsSettingsStrings.VideoHeader; private Bindable hardwareVideoDecoder; - private SettingsCheckbox hwAccelCheckbox; + private FormCheckBox hwAccelCheckbox; [BackgroundDependencyLoader] private void load(FrameworkConfigManager config) @@ -27,10 +28,10 @@ private void load(FrameworkConfigManager config) Children = new Drawable[] { - hwAccelCheckbox = new SettingsCheckbox + new SettingsItemV2(hwAccelCheckbox = new FormCheckBox { - LabelText = GraphicsSettingsStrings.UseHardwareAcceleration, - }, + Caption = GraphicsSettingsStrings.UseHardwareAcceleration, + }), }; hwAccelCheckbox.Current.Default = hardwareVideoDecoder.Default != HardwareVideoDecoder.None; diff --git a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs index 704fa6e907d3..e39dcb59054d 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs @@ -19,11 +19,12 @@ public BindingSettings(KeyBindingPanel keyConfig) { Children = new Drawable[] { - new SettingsButton + new SettingsButtonV2 { Text = BindingSettingsStrings.Configure, TooltipText = BindingSettingsStrings.ChangeBindingsButton, - Action = keyConfig.ToggleVisibility + Action = keyConfig.ToggleVisibility, + Height = 60 }, }; } diff --git a/osu.Game/Overlays/Settings/Sections/Input/JoystickSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/JoystickSettings.cs index 8455c096336a..d5d7540f791e 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/JoystickSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/JoystickSettings.cs @@ -8,25 +8,23 @@ using osu.Framework.Graphics; using osu.Framework.Input.Handlers.Joystick; using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { - public partial class JoystickSettings : SettingsSubsection + public partial class JoystickSettings : InputSubsection { protected override LocalisableString Header => JoystickSettingsStrings.JoystickGamepad; private readonly JoystickHandler joystickHandler; - private readonly Bindable enabled = new BindableBool(true); - - private SettingsSlider deadzoneSlider; - private Bindable handlerDeadzone; private Bindable localDeadzone; public JoystickSettings(JoystickHandler joystickHandler) + : base(joystickHandler) { this.joystickHandler = joystickHandler; } @@ -38,30 +36,22 @@ private void load() handlerDeadzone = joystickHandler.DeadzoneThreshold.GetBoundCopy(); localDeadzone = handlerDeadzone.GetUnboundCopy(); - Children = new Drawable[] + AddRange(new Drawable[] { - new SettingsCheckbox + new SettingsItemV2(new FormSliderBar { - LabelText = CommonStrings.Enabled, - Current = enabled - }, - deadzoneSlider = new SettingsSlider - { - LabelText = JoystickSettingsStrings.DeadzoneThreshold, + Caption = JoystickSettingsStrings.DeadzoneThreshold, KeyboardStep = 0.01f, DisplayAsPercentage = true, Current = localDeadzone, - }, - }; + }) + }); } protected override void LoadComplete() { base.LoadComplete(); - enabled.BindTo(joystickHandler.Enabled); - enabled.BindValueChanged(e => deadzoneSlider.Current.Disabled = !e.NewValue, true); - handlerDeadzone.BindValueChanged(val => { bool disabled = localDeadzone.Disabled; diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.KeyButton.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.KeyButton.cs index adf05a71b9b5..5b986f65eefd 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.KeyButton.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.KeyButton.cs @@ -5,6 +5,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -139,9 +140,21 @@ private void updateHoverState() /// /// A generated from the full input state. /// The key which triggered this update, and should be used as the binding. - public void UpdateKeyCombination(KeyCombination fullState, InputKey triggerKey) => - // TODO: Distinct() can be removed after https://github.com/ppy/osu-framework/pull/6130 is merged. - UpdateKeyCombination(new KeyCombination(fullState.Keys.Where(KeyCombination.IsModifierKey).Append(triggerKey).Distinct().ToArray())); + public void UpdateKeyCombination(KeyCombination fullState, InputKey triggerKey) + { + var keys = fullState.Keys + .Where(KeyCombination.IsModifierKey) + .Append(triggerKey) + .ToArray(); + + // For gameplay bindings, users care about being able to use both left / right shift as different bindings. + // For global bindings, it's better to combine both of these into a virtual key which covers both side modifiers. + var combination = KeyBinding.Value.RulesetName == null + ? keys.Select(k => k.GetVirtualKey() ?? k).ToArray() + : keys; + + UpdateKeyCombination(new KeyCombination(combination)); + } public void UpdateKeyCombination(KeyCombination newCombination) { diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index 083c67817610..cb839b92e796 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -94,6 +94,7 @@ public bool MatchingFilter private Container content = null!; private OsuSpriteText text = null!; + private SettingsRevertToDefaultButton revertButton = null!; private FillFlowContainer cancelAndClearButtons = null!; private FillFlowContainer buttons = null!; @@ -127,27 +128,22 @@ private void load(OverlayColourProvider colourProvider, AudioManager audioManage { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Padding = new MarginPadding { Right = SettingsPanel.CONTENT_MARGINS }; + Padding = new MarginPadding { Right = SettingsPanel.CONTENT_PADDING.Right }; InternalChildren = new Drawable[] { - new Container + revertButton = new SettingsRevertToDefaultButton { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Y, - Width = SettingsPanel.CONTENT_MARGINS, - Child = new RevertToDefaultButton - { - Current = isDefault, - Action = RestoreDefaults, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } + Action = RestoreDefaults, }, new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS }, + Padding = new MarginPadding { Left = SettingsPanel.CONTENT_PADDING.Left }, Children = new Drawable[] { content = new Container @@ -179,7 +175,8 @@ private void load(OverlayColourProvider colourProvider, AudioManager audioManage { AutoSizeAxes = Axes.Both, Anchor = Anchor.TopRight, - Origin = Anchor.TopRight + Origin = Anchor.TopRight, + Spacing = new Vector2(-6, 0), }, cancelAndClearButtons = new FillFlowContainer { @@ -191,8 +188,18 @@ private void load(OverlayColourProvider colourProvider, AudioManager audioManage Spacing = new Vector2(5), Children = new Drawable[] { - new CancelButton { Action = () => finalise(false) }, - new ClearButton { Action = clear }, + new RoundedButton + { + Text = CommonStrings.ButtonsCancel, + Size = new Vector2(80, 20), + Action = () => finalise(false) + }, + new DangerousRoundedButton + { + Text = CommonStrings.ButtonsClear, + Size = new Vector2(80, 20), + Action = clear + }, }, }, new HoverClickSounds() @@ -213,6 +220,19 @@ private void load(OverlayColourProvider colourProvider, AudioManager audioManage keypressSamples[i] = audioManager.Samples.Get($@"Keyboard/key-press-{1 + i}"); } + protected override void LoadComplete() + { + base.LoadComplete(); + + isDefault.BindValueChanged(d => + { + if (d.NewValue) + revertButton.Hide(); + else + revertButton.Show(); + }, true); + } + public void RestoreDefaults() { int i = 0; @@ -473,7 +493,7 @@ private void finalise(bool advanceToNextBinding = true) protected override void OnFocus(FocusEvent e) { - content.AutoSizeDuration = 500; + content.AutoSizeDuration = 250; content.AutoSizeEasing = Easing.OutQuint; cancelAndClearButtons.FadeIn(300, Easing.OutQuint); @@ -538,23 +558,5 @@ private void updateIsDefaultValue() { isDefault.Value = KeyBindings.Select(b => b.KeyCombination).SequenceEqual(Defaults); } - - private partial class CancelButton : RoundedButton - { - public CancelButton() - { - Text = CommonStrings.ButtonsCancel; - Size = new Vector2(80, 20); - } - } - - public partial class ClearButton : DangerousRoundedButton - { - public ClearButton() - { - Text = CommonStrings.ButtonsClear; - Size = new Vector2(80, 20); - } - } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs index cde9f1054959..873ecc3f470b 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs @@ -111,7 +111,7 @@ private void onBindingUpdated(KeyBindingRow sender, KeyBindingRow.KeyBindingUpda } } - public partial class ResetButton : DangerousSettingsButton + public partial class ResetButton : DangerousSettingsButtonV2 { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 3fb4016498f0..1d4538baef52 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -11,30 +9,33 @@ using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Localisation; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Input; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { - public partial class MouseSettings : SettingsSubsection + public partial class MouseSettings : InputSubsection { private readonly MouseHandler mouseHandler; protected override LocalisableString Header => MouseSettingsStrings.Mouse; - private Bindable handlerSensitivity; + private Bindable handlerSensitivity = null!; + private Bindable localSensitivity = null!; + private Bindable windowMode = null!; + private Bindable minimiseOnFocusLoss = null!; + private FormEnumDropdown confineMouseModeSetting = null!; + private Bindable relativeMode = null!; - private Bindable localSensitivity; + private FormCheckBox highPrecisionMouse = null!; - private Bindable windowMode; - private Bindable minimiseOnFocusLoss; - private SettingsEnumDropdown confineMouseModeSetting; - private Bindable relativeMode; + private readonly Bindable highPrecisionMouseNote = new Bindable(); - private SettingsCheckbox highPrecisionMouse; + protected override bool IsToggleable => false; public MouseSettings(MouseHandler mouseHandler) + : base(mouseHandler) { this.mouseHandler = mouseHandler; } @@ -50,38 +51,47 @@ private void load(OsuConfigManager osuConfig, FrameworkConfigManager config) windowMode = config.GetBindable(FrameworkSetting.WindowMode); minimiseOnFocusLoss = config.GetBindable(FrameworkSetting.MinimiseOnFocusLossInFullscreen); - Children = new Drawable[] + AddRange(new Drawable[] { - highPrecisionMouse = new SettingsCheckbox + new SettingsItemV2(highPrecisionMouse = new FormCheckBox { - LabelText = MouseSettingsStrings.HighPrecisionMouse, - TooltipText = MouseSettingsStrings.HighPrecisionMouseTooltip, + Caption = MouseSettingsStrings.HighPrecisionMouse, + HintText = MouseSettingsStrings.HighPrecisionMouseTooltip, Current = relativeMode, + }) + { Keywords = new[] { @"raw", @"input", @"relative", @"cursor", "sensitivity", "speed", "velocity" }, + Note = { BindTarget = highPrecisionMouseNote }, }, - new SensitivitySetting + new SettingsItemV2(new FormSliderBar + { + Caption = MouseSettingsStrings.CursorSensitivity, + Current = localSensitivity, + KeyboardStep = 0.01f, + TransferValueOnCommit = true, + LabelFormat = v => $@"{v:0.##}x", + TooltipFormat = v => localSensitivity.Disabled ? MouseSettingsStrings.EnableHighPrecisionForSensitivityAdjust : $@"{v:0.##}x", + }) { Keywords = new[] { "speed", "velocity" }, - LabelText = MouseSettingsStrings.CursorSensitivity, - Current = localSensitivity }, - confineMouseModeSetting = new SettingsEnumDropdown + new SettingsItemV2(confineMouseModeSetting = new FormEnumDropdown { - LabelText = MouseSettingsStrings.ConfineMouseMode, + Caption = MouseSettingsStrings.ConfineMouseMode, Current = osuConfig.GetBindable(OsuSetting.ConfineMouseMode) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = MouseSettingsStrings.DisableMouseWheelVolumeAdjust, - TooltipText = MouseSettingsStrings.DisableMouseWheelVolumeAdjustTooltip, + Caption = MouseSettingsStrings.DisableMouseWheelVolumeAdjust, + HintText = MouseSettingsStrings.DisableMouseWheelVolumeAdjustTooltip, Current = osuConfig.GetBindable(OsuSetting.MouseDisableWheel) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = MouseSettingsStrings.DisableClicksDuringGameplay, + Caption = MouseSettingsStrings.DisableClicksDuringGameplay, Current = osuConfig.GetBindable(OsuSetting.MouseDisableButtons) - }, - }; + }), + }); } protected override void LoadComplete() @@ -112,9 +122,9 @@ protected override void LoadComplete() case RuntimeInfo.Platform.macOS: case RuntimeInfo.Platform.iOS: if (highPrecision.NewValue) - highPrecisionMouse.SetNoticeText(MouseSettingsStrings.HighPrecisionPlatformWarning, true); + highPrecisionMouseNote.Value = new SettingsNote.Data(MouseSettingsStrings.HighPrecisionPlatformWarning, SettingsNote.Type.Warning); else - highPrecisionMouse.ClearNoticeText(); + highPrecisionMouseNote.Value = null; break; } @@ -131,27 +141,13 @@ private void updateConfineMouseModeSettingVisibility() if (confineModeOverriden) { confineMouseModeSetting.Current.Disabled = true; - confineMouseModeSetting.TooltipText = MouseSettingsStrings.NotApplicableFullscreen; + confineMouseModeSetting.HintText = MouseSettingsStrings.NotApplicableFullscreen; } else { confineMouseModeSetting.Current.Disabled = false; - confineMouseModeSetting.TooltipText = string.Empty; - } - } - - public partial class SensitivitySetting : SettingsSlider - { - public SensitivitySetting() - { - KeyboardStep = 0.01f; - TransferValueOnCommit = true; + confineMouseModeSetting.HintText = default; } } - - public partial class SensitivitySlider : RoundedSliderBar - { - public override LocalisableString TooltipText => Current.Disabled ? MouseSettingsStrings.EnableHighPrecisionForSensitivityAdjust : $"{base.TooltipText}x"; - } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs index 33f4f49173bf..5cd09265509b 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs @@ -41,71 +41,92 @@ public partial class TabletAreaSelection : CompositeDrawable private Box usableFill; private OsuSpriteText usableAreaText; + [Resolved] + private OsuColour colour { get; set; } + public TabletAreaSelection(ITabletHandler handler) { this.handler = handler; - Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }; + Padding = SettingsPanel.CONTENT_PADDING; } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { - InternalChild = tabletContainer = new Container + InternalChildren = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Masking = true, - CornerRadius = 5, - BorderThickness = 2, - BorderColour = colour.Gray3, - Children = new Drawable[] + new Container { - new Box + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + CornerExponent = 2.5f, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = colour.Gray1, - }, - usableAreaContainer = new UsableAreaContainer(handler) - { - Origin = Anchor.Centre, - Children = new Drawable[] + new Box { - usableFill = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.6f, - }, - new Box - { - Colour = Color4.White, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Height = 5, - }, - new Box - { - Colour = Color4.White, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 5, - }, - usableAreaText = new OsuSpriteText + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + tabletContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = 5, + BorderThickness = 2, + BorderColour = colourProvider.Background3, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = Color4.White, - Font = OsuFont.Default.With(size: 12), - Y = 10 + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + usableAreaContainer = new UsableAreaContainer(handler) + { + Origin = Anchor.Centre, + Children = new Drawable[] + { + usableFill = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.6f, + }, + new Box + { + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Height = 5, + }, + new Box + { + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 5, + }, + usableAreaText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.White, + Font = OsuFont.Default.With(size: 12), + Y = 10 + } + } + }, + tabletName = new OsuSpriteText + { + Padding = new MarginPadding(3), + Font = OsuFont.Default.With(size: 8) + }, } } - }, - tabletName = new OsuSpriteText - { - Padding = new MarginPadding(3), - Font = OsuFont.Default.With(size: 8) - }, - } + } + }, }; } @@ -137,7 +158,7 @@ protected override void LoadComplete() rotation.BindValueChanged(val => { usableAreaContainer.RotateTo(val.NewValue, 100, Easing.OutQuint); - tabletContainer.RotateTo(-val.NewValue, 800, Easing.OutQuint); + tabletContainer.RotateTo(-val.NewValue, 400, Easing.OutQuint); checkBounds(); }, true); @@ -169,9 +190,6 @@ private static int greatestCommonDivider(int a, int b) return a; } - [Resolved] - private OsuColour colour { get; set; } - private void checkBounds() { if (tablet.Value == null) diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 6aebec88a989..c6116ddc7b53 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -9,20 +9,24 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Handlers; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Threading; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; using osuTK; using osu.Game.Localisation; using osu.Game.Online.Chat; namespace osu.Game.Overlays.Settings.Sections.Input { - public partial class TabletSettings : SettingsSubsection + public partial class TabletSettings : InputSubsection { public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "area" }); @@ -34,6 +38,8 @@ public partial class TabletSettings : SettingsSubsection private readonly Bindable areaOffset = new Bindable(); private readonly Bindable areaSize = new Bindable(); + private readonly Bindable outputAreaSize = new Bindable(); + private readonly Bindable outputAreaOffset = new Bindable(); private readonly IBindable tablet = new Bindable(); private readonly BindableNumber offsetX = new BindableNumber { MinValue = 0, Precision = 1 }; @@ -46,6 +52,10 @@ public partial class TabletSettings : SettingsSubsection private readonly BindableNumber pressureThreshold = new BindableNumber { MinValue = 0.0f, MaxValue = 1.0f, Precision = 0.005f }; + private Bindable scalingMode = null!; + private Bindable scalingSizeX = null!; + private Bindable scalingSizeY = null!; + [Resolved] private GameHost host { get; set; } @@ -67,66 +77,32 @@ public partial class TabletSettings : SettingsSubsection private FillFlowContainer mainSettings; - private FillFlowContainer noTabletMessage; + private Drawable noTabletMessage; protected override LocalisableString Header => TabletSettingsStrings.Tablet; public TabletSettings(ITabletHandler tabletHandler) + : base((InputHandler)tabletHandler) { this.tabletHandler = tabletHandler; } [BackgroundDependencyLoader] - private void load(OsuColour colours, LocalisationManager localisation) + private void load(OsuColour colours, LocalisationManager localisation, OsuConfigManager osuConfig, OverlayColourProvider colourProvider) { - Children = new Drawable[] - { - new SettingsCheckbox - { - LabelText = CommonStrings.Enabled, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Current = enabled, - }, - noTabletMessage = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }, - Spacing = new Vector2(5f), - Children = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = TabletSettingsStrings.NoTabletDetected, - }, - new LinkFlowContainer(cp => cp.Colour = colours.Yellow) - { - TextAnchor = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }.With(t => - { - t.NewLine(); + scalingMode = osuConfig.GetBindable(OsuSetting.Scaling); + scalingSizeX = osuConfig.GetBindable(OsuSetting.ScalingSizeX); + scalingSizeY = osuConfig.GetBindable(OsuSetting.ScalingSizeY); - const string url = @"https://opentabletdriver.net/Wiki/FAQ/General"; - var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedString(TabletSettingsStrings.NoTabletDetectedDescription(url))); - - t.AddLinks(formattedSource.Text, formattedSource.Links); - }), - } - }, + AddRange(new[] + { + noTabletMessage = new NoTabletMessage(), mainSettings = new FillFlowContainer { Alpha = 0, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0, 8), + Spacing = new Vector2(0, SettingsSection.ITEM_SPACING_V2), Direction = FillDirection.Vertical, Children = new Drawable[] { @@ -135,7 +111,7 @@ private void load(OsuColour colours, LocalisationManager localisation) RelativeSizeAxes = Axes.X, Height = 300, }, - new DangerousSettingsButton + new DangerousSettingsButtonV2 { Text = TabletSettingsStrings.ResetToFullArea, Action = () => @@ -145,82 +121,79 @@ private void load(OsuColour colours, LocalisationManager localisation) areaOffset.SetDefault(); areaSize.SetDefault(); }, - CanBeShown = { BindTarget = enabled } }, - new SettingsButton + new SettingsButtonV2 { Text = TabletSettingsStrings.ConformToCurrentGameAspectRatio, Action = () => { - forceAspectRatio((float)host.Window.ClientSize.Width / host.Window.ClientSize.Height); + float gameplayWidth = host.Window.ClientSize.Width; + float gameplayHeight = host.Window.ClientSize.Height; + + if (scalingMode.Value == ScalingMode.Everything) + { + gameplayWidth *= scalingSizeX.Value; + gameplayHeight *= scalingSizeY.Value; + } + + forceAspectRatio(gameplayWidth / gameplayHeight); }, - CanBeShown = { BindTarget = enabled } }, - new SettingsSlider + new SettingsItemV2(new FormSliderBar { TransferValueOnCommit = true, - LabelText = TabletSettingsStrings.XOffset, + Caption = TabletSettingsStrings.XOffset, Current = offsetX, - CanBeShown = { BindTarget = enabled } - }, - new SettingsSlider + }), + new SettingsItemV2(new FormSliderBar { TransferValueOnCommit = true, - LabelText = TabletSettingsStrings.YOffset, + Caption = TabletSettingsStrings.YOffset, Current = offsetY, - CanBeShown = { BindTarget = enabled } - }, - new SettingsSlider + }), + new SettingsItemV2(new FormSliderBar { TransferValueOnCommit = true, - LabelText = TabletSettingsStrings.Rotation, + Caption = TabletSettingsStrings.Rotation, Current = rotation, - CanBeShown = { BindTarget = enabled } - }, + }), new RotationPresetButtons(tabletHandler) { - Padding = new MarginPadding - { - Horizontal = SettingsPanel.CONTENT_MARGINS - } + Padding = SettingsPanel.CONTENT_PADDING, }, - new SettingsSlider + new SettingsItemV2(new FormSliderBar { TransferValueOnCommit = true, - LabelText = TabletSettingsStrings.AspectRatio, + Caption = TabletSettingsStrings.AspectRatio, Current = aspectRatio, - CanBeShown = { BindTarget = enabled } - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = TabletSettingsStrings.LockAspectRatio, + Caption = TabletSettingsStrings.LockAspectRatio, Current = aspectLock, - CanBeShown = { BindTarget = enabled } - }, - new SettingsSlider + }), + new SettingsItemV2(new FormSliderBar { TransferValueOnCommit = true, - LabelText = CommonStrings.Width, + Caption = CommonStrings.Width, Current = sizeX, - CanBeShown = { BindTarget = enabled } - }, - new SettingsSlider + }), + new SettingsItemV2(new FormSliderBar { TransferValueOnCommit = true, - LabelText = CommonStrings.Height, + Caption = CommonStrings.Height, Current = sizeY, - CanBeShown = { BindTarget = enabled } - }, - new SettingsPercentageSlider + }), + new SettingsItemV2(new FormSliderBar { TransferValueOnCommit = true, - LabelText = TabletSettingsStrings.TipPressureForClick, + Caption = TabletSettingsStrings.TipPressureForClick, Current = pressureThreshold, - CanBeShown = { BindTarget = enabled } - }, + DisplayAsPercentage = true, + }), } }, - }; + }); } protected override void LoadComplete() @@ -249,6 +222,9 @@ protected override void LoadComplete() sizeY.Value = val.NewValue.Y; }), true); + outputAreaSize.BindTo(tabletHandler.OutputAreaSize); + outputAreaOffset.BindTo(tabletHandler.OutputAreaOffset); + sizeX.BindValueChanged(val => { areaSize.Value = new Vector2(val.NewValue, areaSize.Value.Y); @@ -366,5 +342,91 @@ private void forceAspectRatio(float aspectRatio) private static float getHeight(float width, float aspectRatio) => width / aspectRatio; private static float getWidth(float height, float aspectRatio) => height * aspectRatio; + + private partial class NoTabletMessage : CompositeDrawable + { + private readonly Bindable currentLanguage = new Bindable(); + private LinkFlowContainer linkContainer; + + [Resolved] + private LocalisationManager localisation { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuGameBase game, OsuColour colours, OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = SettingsPanel.CONTENT_PADDING; + + InternalChild = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 5, + CornerExponent = 2.5f, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Dark2, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5f), + Padding = new MarginPadding { Horizontal = 8, Vertical = 10 }, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = TabletSettingsStrings.NoTabletDetected, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Colour = colourProvider.Content2, + }, + linkContainer = new LinkFlowContainer(cp => + { + cp.Colour = colours.Orange1; + cp.Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold); + }) + { + TextAnchor = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + } + }, + }, + }; + + if (game != null) + currentLanguage.BindTo(game.CurrentLanguage); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentLanguage.BindValueChanged(_ => + // schedule required because `LocalisationManager` won't have new language set correctly yet. + Schedule(() => + { + linkContainer.Clear(); + linkContainer.NewLine(); + + const string url = @"https://opentabletdriver.net/Wiki/FAQ/General"; + var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedString(TabletSettingsStrings.NoTabletDetectedDescription(url))); + + linkContainer.AddLinks(formattedSource.Text, formattedSource.Links); + }), true); + } + } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs index 0056de667471..5499d4964845 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs @@ -8,6 +8,7 @@ using osu.Framework.Input.Handlers; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input @@ -15,34 +16,25 @@ namespace osu.Game.Overlays.Settings.Sections.Input /// /// Touch input settings subsection common to all touch handlers (even on different platforms). /// - public partial class TouchSettings : SettingsSubsection + public partial class TouchSettings : InputSubsection { - private readonly InputHandler handler; - protected override LocalisableString Header => TouchSettingsStrings.Touch; + protected override bool IsToggleable => !RuntimeInfo.IsMobile; + public TouchSettings(InputHandler handler) + : base(handler) { - this.handler = handler; } [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig) { - if (!RuntimeInfo.IsMobile) // don't allow disabling the only input method (touch) on mobile. - { - Add(new SettingsCheckbox - { - LabelText = CommonStrings.Enabled, - Current = handler.Enabled - }); - } - - Add(new SettingsCheckbox + Add(new SettingsItemV2(new FormCheckBox { - LabelText = TouchSettingsStrings.DisableTapsDuringGameplay, + Caption = TouchSettingsStrings.DisableTapsDuringGameplay, Current = osuConfig.GetBindable(OsuSetting.TouchDisableGameplayTaps) - }); + })); } public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { @"touchscreen" }); diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index 0204aa5e644e..0b22022362ea 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Handlers; using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Graphics; @@ -45,30 +44,5 @@ private void load(GameHost host, OsuGameBase game) Add(handlerSection); } } - - public partial class HandlerSection : SettingsSubsection - { - private readonly InputHandler handler; - - public HandlerSection(InputHandler handler) - { - this.handler = handler; - } - - [BackgroundDependencyLoader] - private void load() - { - Children = new Drawable[] - { - new SettingsCheckbox - { - LabelText = CommonStrings.Enabled, - Current = handler.Enabled - }, - }; - } - - protected override LocalisableString Header => handler.Description; - } } } diff --git a/osu.Game/Overlays/Settings/Sections/InputSubsection.cs b/osu.Game/Overlays/Settings/Sections/InputSubsection.cs new file mode 100644 index 000000000000..68a19bd7a3d1 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/InputSubsection.cs @@ -0,0 +1,185 @@ +// 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.Events; +using osu.Framework.Input.Handlers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK.Graphics; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections +{ + public partial class InputSubsection : SettingsSubsection + { + private readonly InputHandler handler; + + protected override LocalisableString Header => handler.Description; + + /// + /// Whether the input handler can be toggled on/off by the user. + /// + protected virtual bool IsToggleable => true; + + private readonly BindableBool handlerEnabled = new BindableBool(); + + private ToggleableHeader header = null!; + + public InputSubsection(InputHandler handler) + { + this.handler = handler; + } + + protected override Drawable CreateHeader() => header = new ToggleableHeader(Header, IsToggleable) + { + Current = { BindTarget = handlerEnabled }, + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + handlerEnabled.BindTo(handler.Enabled); + handlerEnabled.BindValueChanged(updateEnabledState, true); + + // We use masking to hide the content of these sections. + FlowContent.Masking = true; + } + + private void updateEnabledState(ValueChangedEvent state) + { + // set negative bottom margin to not have too much vertical gap between disabled input subsections. + bool negativeBottomMargin = !handlerEnabled.Value || FlowContent.Count == 0; + header.TransformTo(nameof(Margin), new MarginPadding { Bottom = negativeBottomMargin ? -VERTICAL_PADDING : 0 }, 300, Easing.OutQuint); + + // Avoid crashes from toggling `AutoSizeAxes` while active `AutoSizeDuration` transforms are still running. + // This is probably a framework bug. + FlowContent.ClearTransforms(); + + if (!handlerEnabled.Value) + { + FlowContent.AutoSizeAxes = Axes.None; + FlowContent.ResizeHeightTo(0, 300, Easing.OutQuint); + } + else + { + // enable auto size transform momentarily for smooth pop in animation, and disable it right after the transform is added. + // we don't want this specification to apply when a dropdown in the input settings is being open, it causes too slow animation. + // (try removing the schedule below then watch a settings dropdown menu opening animation). + FlowContent.AutoSizeDuration = state.NewValue == state.OldValue ? 0 : 300; + FlowContent.AutoSizeEasing = Easing.OutQuint; + FlowContent.AutoSizeAxes = Axes.Y; + + ScheduleAfterChildren(() => FlowContent.AutoSizeDuration = 0); + } + } + + private partial class ToggleableHeader : CompositeDrawable + { + private readonly LocalisableString text; + private readonly bool toggleable; + + public readonly BindableBool Current = new BindableBool(true); + + public ToggleableHeader(LocalisableString text, bool toggleable) + { + Padding = SettingsPanel.CONTENT_PADDING; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + this.text = text; + this.toggleable = toggleable; + } + + private SwitchButton switchButton = null!; + private OsuSpriteText headerText = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + switchButton = new SwitchButton + { + ExpandOnCurrent = false, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 15, + Height = 22, + }, + headerText = new OsuSpriteText + { + Text = InputSettingsStrings.Device(text), + Font = OsuFont.Style.Heading2, + Margin = new MarginPadding { Vertical = 12 }, + X = 18, + Y = -1, + }, + new HoverSounds(), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + switchButton.Current.ValueChanged += v => Current.Value = v.NewValue; + + Current.BindValueChanged(v => + { + switchButton.Current.Disabled = false; + switchButton.Current.Value = v.NewValue; + switchButton.Current.Disabled = !toggleable; + + updateDisplay(); + }, true); + } + + protected override bool OnHover(HoverEvent e) + { + updateDisplay(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateDisplay(); + base.OnHoverLost(e); + } + + protected override bool OnClick(ClickEvent e) + { + if (toggleable) + { + Current.Toggle(); + switchButton.PlaySample(Current.Value); + } + + updateDisplay(); + return true; + } + + private void updateDisplay() + { + // default, toggled on (or not toggleable) + Color4 col = colourProvider.Content1; + + if (toggleable && !Current.Value) + col = IsHovered ? colourProvider.Light1 : colourProvider.Foreground1; + + headerText.FadeColour(col, 300, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs index 597e03fab21e..a4d20fb459cf 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs @@ -14,16 +14,16 @@ public partial class BeatmapSettings : SettingsSubsection { protected override LocalisableString Header => CommonStrings.Beatmaps; - private SettingsButton deleteBeatmapsButton = null!; - private SettingsButton deleteBeatmapVideosButton = null!; - private SettingsButton resetOffsetsButton = null!; - private SettingsButton restoreButton = null!; - private SettingsButton undeleteButton = null!; + private SettingsButtonV2 deleteBeatmapsButton = null!; + private SettingsButtonV2 deleteBeatmapVideosButton = null!; + private SettingsButtonV2 resetOffsetsButton = null!; + private SettingsButtonV2 restoreButton = null!; + private SettingsButtonV2 undeleteButton = null!; [BackgroundDependencyLoader] private void load(BeatmapManager beatmaps, IDialogOverlay? dialogOverlay) { - Add(deleteBeatmapsButton = new DangerousSettingsButton + Add(deleteBeatmapsButton = new DangerousSettingsButtonV2 { Text = MaintenanceSettingsStrings.DeleteAllBeatmaps, Action = () => @@ -36,7 +36,7 @@ private void load(BeatmapManager beatmaps, IDialogOverlay? dialogOverlay) } }); - Add(deleteBeatmapVideosButton = new DangerousSettingsButton + Add(deleteBeatmapVideosButton = new DangerousSettingsButtonV2 { Text = MaintenanceSettingsStrings.DeleteAllBeatmapVideos, Action = () => @@ -49,7 +49,7 @@ private void load(BeatmapManager beatmaps, IDialogOverlay? dialogOverlay) } }); - Add(resetOffsetsButton = new DangerousSettingsButton + Add(resetOffsetsButton = new DangerousSettingsButtonV2 { Text = MaintenanceSettingsStrings.ResetAllOffsets, Action = () => @@ -64,7 +64,7 @@ private void load(BeatmapManager beatmaps, IDialogOverlay? dialogOverlay) AddRange(new Drawable[] { - restoreButton = new SettingsButton + restoreButton = new SettingsButtonV2 { Text = MaintenanceSettingsStrings.RestoreAllHiddenDifficulties, Action = () => @@ -73,7 +73,7 @@ private void load(BeatmapManager beatmaps, IDialogOverlay? dialogOverlay) Task.Run(beatmaps.RestoreAll).ContinueWith(_ => Schedule(() => restoreButton.Enabled.Value = true)); } }, - undeleteButton = new SettingsButton + undeleteButton = new SettingsButtonV2 { Text = MaintenanceSettingsStrings.RestoreAllRecentlyDeletedBeatmaps, Action = () => diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs index b1c44aa93c04..922969035dc7 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs @@ -24,7 +24,7 @@ public partial class CollectionsSettings : SettingsSubsection [BackgroundDependencyLoader] private void load(IDialogOverlay? dialogOverlay) { - Add(new DangerousSettingsButton + Add(new DangerousSettingsButtonV2 { Text = MaintenanceSettingsStrings.DeleteAllCollections, Action = () => diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index 47314dcafef4..e8345c1946dd 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -29,7 +29,7 @@ private void load(OsuGameBase game, GameHost host, IPerformFromScreenRunner? per AddRange(new Drawable[] { - new SettingsButton + new SettingsButtonV2 { Text = DebugSettingsStrings.ImportFiles, Action = () => @@ -40,12 +40,19 @@ private void load(OsuGameBase game, GameHost host, IPerformFromScreenRunner? per performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())); }, }, - new SettingsButton + new SettingsButtonV2 { Text = DebugSettingsStrings.RunLatencyCertifier, Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen())) } }); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + selector?.Dispose(); + } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs index c0363851efaa..ce33039d688f 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -15,12 +15,15 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Screens; +using osu.Game.Screens.Backgrounds; using osuTK; namespace osu.Game.Overlays.Settings.Sections.Maintenance { public partial class MigrationRunScreen : OsuScreen { + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBlack(); + private readonly DirectoryInfo destination; [Resolved(canBeNull: true)] diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs index 9c55308abe83..17101c65260e 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs @@ -25,15 +25,15 @@ public partial class ModPresetSettings : SettingsSubsection [Resolved] private INotificationOverlay? notificationOverlay { get; set; } - private SettingsButton undeleteButton = null!; - private SettingsButton deleteAllButton = null!; + private SettingsButtonV2 undeleteButton = null!; + private SettingsButtonV2 deleteAllButton = null!; [BackgroundDependencyLoader] private void load(IDialogOverlay? dialogOverlay) { AddRange(new Drawable[] { - deleteAllButton = new DangerousSettingsButton + deleteAllButton = new DangerousSettingsButtonV2 { Text = MaintenanceSettingsStrings.DeleteAllModPresets, Action = () => @@ -45,7 +45,7 @@ private void load(IDialogOverlay? dialogOverlay) }, DeleteConfirmationContentStrings.ModPresets)); } }, - undeleteButton = new SettingsButton + undeleteButton = new SettingsButtonV2 { Text = MaintenanceSettingsStrings.RestoreAllRecentlyDeletedModPresets, Action = () => Task.Run(undeleteModPresets).ContinueWith(t => Schedule(onModPresetsUndeleted, t)) diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs index 235f239c7c95..b4a1d449ed16 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs @@ -13,12 +13,12 @@ public partial class ScoreSettings : SettingsSubsection { protected override LocalisableString Header => CommonStrings.Scores; - private SettingsButton deleteScoresButton = null!; + private SettingsButtonV2 deleteScoresButton = null!; [BackgroundDependencyLoader] private void load(ScoreManager scores, IDialogOverlay? dialogOverlay) { - Add(deleteScoresButton = new DangerousSettingsButton + Add(deleteScoresButton = new DangerousSettingsButtonV2 { Text = MaintenanceSettingsStrings.DeleteAllScores, Action = () => diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs index e962118a368a..85b4898e0508 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs @@ -13,12 +13,12 @@ public partial class SkinSettings : SettingsSubsection { protected override LocalisableString Header => CommonStrings.Skins; - private SettingsButton deleteSkinsButton = null!; + private SettingsButtonV2 deleteSkinsButton = null!; [BackgroundDependencyLoader] private void load(SkinManager skins, IDialogOverlay? dialogOverlay) { - Add(deleteSkinsButton = new DangerousSettingsButton + Add(deleteSkinsButton = new DangerousSettingsButtonV2 { Text = MaintenanceSettingsStrings.DeleteAllSkins, Action = () => diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs index fcbc603c8375..ff10a0a84e0e 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; +using osu.Game.Localisation; using osu.Game.Overlays.Dialog; using osu.Game.Screens; @@ -17,20 +18,20 @@ public partial class StableDirectoryLocationDialog : PopupDialog public StableDirectoryLocationDialog(TaskCompletionSource taskCompletionSource) { - HeaderText = "Failed to automatically locate an osu!stable installation."; - BodyText = "An existing install could not be located. If you know where it is, you can help locate it."; + HeaderText = DialogStrings.StableDirectoryLocationHeaderText; + BodyText = DialogStrings.StableDirectoryLocationBodyText; Icon = FontAwesome.Solid.QuestionCircle; Buttons = new PopupDialogButton[] { new PopupDialogOkButton { - Text = "Sure! I know where it is located!", + Text = DialogStrings.StableDirectoryLocationOkButton, Action = () => Schedule(() => performer.PerformFromScreen(screen => screen.Push(new StableDirectorySelectScreen(taskCompletionSource)))) }, new PopupDialogCancelButton { - Text = "Actually I don't have osu!stable installed.", + Text = DialogStrings.StableDirectoryLocationCancelButton, Action = () => taskCompletionSource.TrySetCanceled() } }; diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs index 3f12b9c0df78..6d95408ee654 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs @@ -8,6 +8,7 @@ using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Database; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Maintenance { @@ -22,7 +23,7 @@ public partial class StableDirectorySelectScreen : DirectorySelectScreen protected override bool IsValidDirectory(DirectoryInfo? info) => legacyImportManager.IsUsableForStableImport(info, out _); - public override LocalisableString HeaderText => "Please select your osu!stable install location"; + public override LocalisableString HeaderText => MaintenanceSettingsStrings.StableDirectorySelectHeader; public StableDirectorySelectScreen(TaskCompletionSource taskCompletionSource) { diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index 608c6ef1b291..227d3feeaf23 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Online @@ -18,27 +19,27 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = OnlineSettingsStrings.NotifyOnMentioned, + Caption = OnlineSettingsStrings.NotifyOnMentioned, Current = config.GetBindable(OsuSetting.NotifyOnUsernameMentioned) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = OnlineSettingsStrings.NotifyOnPrivateMessage, + Caption = OnlineSettingsStrings.NotifyOnPrivateMessage, Current = config.GetBindable(OsuSetting.NotifyOnPrivateMessage) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = OnlineSettingsStrings.NotifyOnFriendPresenceChange, - TooltipText = OnlineSettingsStrings.NotifyOnFriendPresenceChangeTooltip, + Caption = OnlineSettingsStrings.NotifyOnFriendPresenceChange, + HintText = OnlineSettingsStrings.NotifyOnFriendPresenceChangeTooltip, Current = config.GetBindable(OsuSetting.NotifyOnFriendPresenceChange), - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = OnlineSettingsStrings.HideCountryFlags, + Caption = OnlineSettingsStrings.HideCountryFlags, Current = config.GetBindable(OsuSetting.HideCountryFlags) - }, + }), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs index 3d0fac32cfca..3445c67b95cc 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Online @@ -18,11 +19,11 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = OnlineSettingsStrings.DiscordRichPresence, + Caption = OnlineSettingsStrings.DiscordRichPresence, Current = config.GetBindable(OsuSetting.DiscordRichPresence) - } + }), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs index ce5c85bed097..bcb1d91547ed 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Online @@ -18,28 +19,34 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = OnlineSettingsStrings.ExternalLinkWarning, + Caption = OnlineSettingsStrings.ExternalLinkWarning, Current = config.GetBindable(OsuSetting.ExternalLinkWarning) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - LabelText = OnlineSettingsStrings.PreferNoVideo, - Keywords = new[] { "no-video" }, + Caption = OnlineSettingsStrings.PreferNoVideo, Current = config.GetBindable(OsuSetting.PreferNoVideo) + }) + { + Keywords = new[] { "no-video" }, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = OnlineSettingsStrings.AutomaticallyDownloadMissingBeatmaps, - Keywords = new[] { "spectator", "replay" }, + Caption = OnlineSettingsStrings.AutomaticallyDownloadMissingBeatmaps, Current = config.GetBindable(OsuSetting.AutomaticallyDownloadMissingBeatmaps), + }) + { + Keywords = new[] { "spectator", "replay" }, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = OnlineSettingsStrings.ShowExplicitContent, - Keywords = new[] { "nsfw", "18+", "offensive" }, + Caption = OnlineSettingsStrings.ShowExplicitContent, Current = config.GetBindable(OsuSetting.ShowOnlineExplicitContent), + }) + { + Keywords = new[] { "nsfw", "18+", "offensive" }, } }; } diff --git a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs index 626264151f6b..fb9849fd35fa 100644 --- a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs +++ b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs @@ -1,12 +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 System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Framework.Logging; using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Rulesets; @@ -34,9 +34,9 @@ private void load(RulesetStore rulesets) if (section != null) Add(section); } - catch + catch (Exception e) { - Logger.Log($"Failed to load ruleset settings for {ruleset.RulesetInfo.Name}. Please check for an update from the developer.", level: LogLevel.Error); + RulesetStore.LogRulesetFailure(ruleset.RulesetInfo, e); } } } diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 2c24a5b277d2..8d8c73b14e1a 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -22,8 +22,8 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Overlays.Dialog; using osu.Game.Overlays.SkinEditor; -using osu.Game.Screens.Select; using osu.Game.Skinning; using osuTK; using Realms; @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Settings.Sections { public partial class SkinSection : SettingsSection { - private SkinSettingsDropdown skinDropdown; + private SkinDropdown skinDropdown; public override LocalisableString Header => SkinSettingsStrings.SkinSectionHeader; @@ -42,11 +42,7 @@ public partial class SkinSection : SettingsSection Icon = OsuIcon.SkinB }; - private static readonly Live random_skin_info = new SkinInfo - { - ID = SkinInfo.RANDOM_SKIN, - Name = "", - }.ToLiveUnmanaged(); + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "skins" }); private readonly List> dropdownItems = new List>(); @@ -63,30 +59,28 @@ private void load([CanBeNull] SkinEditorOverlay skinEditor) { Children = new Drawable[] { - skinDropdown = new SkinSettingsDropdown + new SettingsItemV2(skinDropdown = new SkinDropdown { AlwaysShowSearchBar = true, AllowNonContiguousMatching = true, - LabelText = SkinSettingsStrings.CurrentSkin, + Caption = SkinSettingsStrings.CurrentSkin, Current = skins.CurrentSkinInfo, - Keywords = new[] { @"skins" }, - }, + }), new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), - Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, + Padding = SettingsPanel.CONTENT_PADDING, Children = new Drawable[] { // This is all super-temporary until we move skin settings to their own panel / overlay. - new RenameSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 }, - new ExportSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 }, - new DeleteSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 110 }, + new RenameSkinButton { Padding = new MarginPadding { Right = 2.5f }, RelativeSizeAxes = Axes.X, Width = 1 / 3f }, + new ExportSkinButton { Padding = new MarginPadding { Horizontal = 2.5f }, RelativeSizeAxes = Axes.X, Width = 1 / 3f }, + new DeleteSkinButton { Padding = new MarginPadding { Left = 2.5f }, RelativeSizeAxes = Axes.X, Width = 1 / 3f }, } }, - new SettingsButton + new SettingsButtonV2 { Text = SkinSettingsStrings.SkinLayoutEditor, Action = () => skinEditor?.ToggleVisibility(), @@ -104,7 +98,7 @@ protected override void LoadComplete() skinDropdown.Current.BindValueChanged(skin => { - if (skin.NewValue == random_skin_info) + if (skin.NewValue.ID == SkinInfo.RANDOM_SKIN) { // before selecting random, set the skin back to the previous selection. // this is done because at this point it will be random_skin_info, and would @@ -121,21 +115,9 @@ private void skinsChanged(IRealmCollection sender, ChangeSet changes) // Because we are using `Live<>` in this class, we don't need to worry about this scenario too much. if (!sender.Any()) return; - // For simplicity repopulate the full list. - // In the future we should change this to properly handle ChangeSet events. dropdownItems.Clear(); - - dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_SKIN).ToLive(realm)); - dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_PRO_SKIN).ToLive(realm)); - dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.TRIANGLES_SKIN).ToLive(realm)); - dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.CLASSIC_SKIN).ToLive(realm)); - dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.RETRO_SKIN).ToLive(realm)); - - dropdownItems.Add(random_skin_info); - - foreach (var skin in sender.Where(s => !s.Protected)) - dropdownItems.Add(skin.ToLive(realm)); + dropdownItems.AddRange(skins.GetAllUsableSkins()); Schedule(() => skinDropdown.Items = dropdownItems); } @@ -147,17 +129,12 @@ protected override void Dispose(bool isDisposing) realmSubscription?.Dispose(); } - private partial class SkinSettingsDropdown : SettingsDropdown> + private partial class SkinDropdown : FormDropdown> { - protected override OsuDropdown> CreateDropdown() => new SkinDropdownControl(); - - private partial class SkinDropdownControl : DropdownControl - { - protected override LocalisableString GenerateItemText(Live item) => item.ToString(); - } + protected override LocalisableString GenerateItemText(Live item) => item.ToString(); } - public partial class RenameSkinButton : SettingsButton, IHasPopover + public partial class RenameSkinButton : SettingsButtonV2, IHasPopover { [Resolved] private SkinManager skins { get; set; } @@ -188,7 +165,7 @@ public Popover GetPopover() } } - public partial class ExportSkinButton : SettingsButton + public partial class ExportSkinButton : SettingsButtonV2 { [Resolved] private SkinManager skins { get; set; } @@ -226,7 +203,7 @@ private void export() } } - public partial class DeleteSkinButton : DangerousSettingsButton + public partial class DeleteSkinButton : DangerousSettingsButtonV2 { [Resolved] private SkinManager skins { get; set; } @@ -260,6 +237,27 @@ private void delete() } } + public partial class SkinDeleteDialog : DeletionDialog + { + private readonly Skin skin; + + public SkinDeleteDialog(Skin skin) + { + this.skin = skin; + BodyText = skin.SkinInfo.Value.Name; + } + + [BackgroundDependencyLoader] + private void load(SkinManager manager) + { + DangerousAction = () => + { + manager.Delete(skin.SkinInfo.Value); + manager.CurrentSkinInfo.SetDefault(); + }; + } + } + public partial class RenameSkinPopover : OsuPopover { [Resolved] @@ -284,7 +282,7 @@ public RenameSkinPopover() { textBox = new FocusedTextBox { - PlaceholderText = @"Skin name", + PlaceholderText = SkinSettingsStrings.SkinName, FontSize = OsuFont.DEFAULT_FONT_SIZE, RelativeSizeAxes = Axes.X, SelectAllOnFocus = true, @@ -294,7 +292,7 @@ public RenameSkinPopover() Height = 40, RelativeSizeAxes = Axes.X, MatchingFilter = true, - Text = "Save", + Text = WebCommonStrings.ButtonsSave, } } }; diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs index 3f39980b43bd..62ecfbc0598c 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs @@ -3,9 +3,10 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.UserInterface @@ -19,29 +20,36 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = UserInterfaceStrings.CursorRotation, + Caption = UserInterfaceStrings.CursorRotation, Current = config.GetBindable(OsuSetting.CursorRotation) + }) + { + Keywords = [@"spin"], }, - new SettingsSlider> + new SettingsItemV2(new FormSliderBar { - LabelText = UserInterfaceStrings.MenuCursorSize, + Caption = UserInterfaceStrings.MenuCursorSize, Current = config.GetBindable(OsuSetting.MenuCursorSize), - KeyboardStep = 0.01f - }, - new SettingsCheckbox + KeyboardStep = 0.01f, + LabelFormat = v => $"{v:0.##}x" + }), + new SettingsItemV2(new FormCheckBox { - LabelText = UserInterfaceStrings.Parallax, + Caption = UserInterfaceStrings.Parallax, Current = config.GetBindable(OsuSetting.MenuParallax) - }, - new SettingsSlider + }), + new SettingsItemV2(new FormSliderBar { - ClassicDefault = 0, - LabelText = UserInterfaceStrings.HoldToConfirmActivationTime, + Caption = UserInterfaceStrings.HoldToConfirmActivationTime, Current = config.GetBindable(OsuSetting.UIHoldActivationDelay), - Keywords = new[] { @"delay" }, - KeyboardStep = 50 + KeyboardStep = 50, + LabelFormat = v => $"{v:N0} ms", + }) + { + Keywords = [@"delay"], + ApplyClassicDefault = c => ((IHasCurrentValue)c).Current.Value = 0, }, }; } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index c50d56b458f0..e38dcbc0060e 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -1,13 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -18,9 +17,9 @@ public partial class MainMenuSettings : SettingsSubsection { protected override LocalisableString Header => UserInterfaceStrings.MainMenuHeader; - private IBindable user; + private IBindable user = null!; - private SettingsEnumDropdown backgroundSourceDropdown; + private readonly Bindable backgroundSourceNote = new Bindable(); [BackgroundDependencyLoader] private void load(OsuConfigManager config, IAPIProvider api) @@ -29,38 +28,45 @@ private void load(OsuConfigManager config, IAPIProvider api) Children = new Drawable[] { - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = UserInterfaceStrings.ShowMenuTips, + Caption = UserInterfaceStrings.ShowMenuTips, Current = config.GetBindable(OsuSetting.MenuTips) - }, - new SettingsCheckbox + }), + new SettingsItemV2(new FormCheckBox { - Keywords = new[] { "intro", "welcome" }, - LabelText = UserInterfaceStrings.InterfaceVoices, + Caption = UserInterfaceStrings.InterfaceVoices, Current = config.GetBindable(OsuSetting.MenuVoice) - }, - new SettingsCheckbox + }) { Keywords = new[] { "intro", "welcome" }, - LabelText = UserInterfaceStrings.OsuMusicTheme, + }, + new SettingsItemV2(new FormCheckBox + { + Caption = UserInterfaceStrings.OsuMusicTheme, Current = config.GetBindable(OsuSetting.MenuMusic) + }) + { + Keywords = new[] { "intro", "welcome" }, }, - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = UserInterfaceStrings.IntroSequence, + Caption = UserInterfaceStrings.IntroSequence, Current = config.GetBindable(OsuSetting.IntroSequence), - }, - backgroundSourceDropdown = new SettingsEnumDropdown + }), + new SettingsItemV2(new FormEnumDropdown { - LabelText = UserInterfaceStrings.BackgroundSource, + Caption = UserInterfaceStrings.BackgroundSource, Current = config.GetBindable(OsuSetting.MenuBackgroundSource), + }) + { + Note = { BindTarget = backgroundSourceNote }, }, - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = UserInterfaceStrings.SeasonalBackgrounds, + Caption = UserInterfaceStrings.SeasonalBackgrounds, Current = config.GetBindable(OsuSetting.SeasonalBackgroundMode), - } + }) }; } @@ -71,9 +77,9 @@ protected override void LoadComplete() user.BindValueChanged(u => { if (u.NewValue?.IsSupporter != true) - backgroundSourceDropdown.SetNoticeText(UserInterfaceStrings.NotSupporterNote, true); + backgroundSourceNote.Value = new SettingsNote.Data(UserInterfaceStrings.NotSupporterNote, SettingsNote.Type.Informational); else - backgroundSourceDropdown.ClearNoticeText(); + backgroundSourceNote.Value = null; }, true); } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index d15008f858d1..83ee3eb09aae 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -3,8 +3,10 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays.Mods.Input; @@ -19,35 +21,40 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = UserInterfaceStrings.ShowConvertedBeatmaps, + Caption = UserInterfaceStrings.ShowConvertedBeatmaps, Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), + }) + { Keywords = new[] { "converts", "converted" } }, - new SettingsEnumDropdown + new SettingsItemV2(new FormEnumDropdown { - LabelText = UserInterfaceStrings.RandomSelectionAlgorithm, + Caption = UserInterfaceStrings.RandomSelectionAlgorithm, Current = config.GetBindable(OsuSetting.RandomSelectAlgorithm), - }, - new SettingsEnumDropdown + }), + new SettingsItemV2(new FormEnumDropdown { - LabelText = UserInterfaceStrings.ModSelectHotkeyStyle, + Caption = UserInterfaceStrings.ModSelectHotkeyStyle, Current = config.GetBindable(OsuSetting.ModSelectHotkeyStyle), - ClassicDefault = ModSelectHotkeyStyle.Classic + }) + { + ApplyClassicDefault = c => ((IHasCurrentValue)c).Current.Value = ModSelectHotkeyStyle.Classic, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = UserInterfaceStrings.ModSelectTextSearchStartsActive, + Caption = UserInterfaceStrings.ModSelectTextSearchStartsActive, Current = config.GetBindable(OsuSetting.ModSelectTextSearchStartsActive), - ClassicDefault = false + }) + { + ApplyClassicDefault = c => ((IHasCurrentValue)c).Current.Value = false, }, - new SettingsCheckbox + new SettingsItemV2(new FormCheckBox { - LabelText = GameplaySettingsStrings.BackgroundBlur, + Caption = GameplaySettingsStrings.BackgroundBlur, Current = config.GetBindable(OsuSetting.SongSelectBackgroundBlur), - ClassicDefault = false, - } + }), }; } } diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index 196ddca95307..0033543fdb13 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -16,7 +16,12 @@ public partial class SettingsButton : RoundedButton, IConditionalFilterable public SettingsButton() { RelativeSizeAxes = Axes.X; - Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }; + Margin = new MarginPadding { Vertical = -5 }; + Padding = new MarginPadding + { + Left = SettingsPanel.CONTENT_MARGINS, + Right = SettingsPanel.CONTENT_MARGINS, + }; } public IEnumerable Keywords { get; set; } = Array.Empty(); diff --git a/osu.Game/Overlays/Settings/SettingsButtonV2.cs b/osu.Game/Overlays/Settings/SettingsButtonV2.cs new file mode 100644 index 000000000000..18d1c47d7a0c --- /dev/null +++ b/osu.Game/Overlays/Settings/SettingsButtonV2.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Overlays.Settings +{ + public partial class SettingsButtonV2 : RoundedButton, IConditionalFilterable + { + public SettingsButtonV2() + { + RelativeSizeAxes = Axes.X; + Padding = SettingsPanel.CONTENT_PADDING; + } + + public IEnumerable Keywords { get; set; } = Array.Empty(); + + public BindableBool CanBeShown { get; } = new BindableBool(true); + IBindable IConditionalFilterable.CanBeShown => CanBeShown; + + public override IEnumerable FilterTerms + { + get + { + if (TooltipText != default) + yield return TooltipText; + + foreach (string s in Keywords) + yield return s; + + foreach (LocalisableString s in base.FilterTerms) + yield return s; + } + } + } +} diff --git a/osu.Game/Overlays/Settings/SettingsFooter.cs b/osu.Game/Overlays/Settings/SettingsFooter.cs index f50fca418d39..a32dec4ca8c3 100644 --- a/osu.Game/Overlays/Settings/SettingsFooter.cs +++ b/osu.Game/Overlays/Settings/SettingsFooter.cs @@ -1,17 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Logging; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; @@ -26,7 +27,7 @@ private void load(OsuGameBase game, RulesetStore rulesets) RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Direction = FillDirection.Vertical; - Padding = new MarginPadding { Top = 20, Bottom = 30, Horizontal = SettingsPanel.CONTENT_MARGINS }; + Padding = new MarginPadding { Top = 20, Bottom = 30, Left = SettingsPanel.CONTENT_PADDING.Left, Right = SettingsPanel.CONTENT_PADDING.Right }; FillFlowContainer modes; @@ -71,9 +72,9 @@ private void load(OsuGameBase game, RulesetStore rulesets) modes.Add(icon); } - catch + catch (Exception e) { - Logger.Log($"Could not create ruleset icon for {ruleset.Name}. Please check for an update from the developer.", level: LogLevel.Error); + RulesetStore.LogRulesetFailure(ruleset, e); } } } @@ -116,7 +117,7 @@ private void load(ChangelogOverlay? changelog) public MenuItem[] ContextMenuItems => new MenuItem[] { - new OsuMenuItem("Copy version", MenuItemType.Standard, () => game?.CopyToClipboard(version)) + new OsuMenuItem(SettingsStrings.CopyVersion, MenuItemType.Standard, () => game?.CopyToClipboard(version)) }; } } diff --git a/osu.Game/Overlays/Settings/SettingsHeader.cs b/osu.Game/Overlays/Settings/SettingsHeader.cs index 8d155fd01e14..c94ce8e1c83e 100644 --- a/osu.Game/Overlays/Settings/SettingsHeader.cs +++ b/osu.Game/Overlays/Settings/SettingsHeader.cs @@ -35,9 +35,9 @@ private void load(OverlayColourProvider colourProvider) RelativeSizeAxes = Axes.X, Padding = new MarginPadding { - Horizontal = SettingsPanel.CONTENT_MARGINS, - Top = Toolbar.Toolbar.TOOLTIP_HEIGHT, - Bottom = 30 + Left = SettingsPanel.CONTENT_PADDING.Left, + Right = SettingsPanel.CONTENT_PADDING.Right, + Vertical = 20, } }.With(flow => { diff --git a/osu.Game/Overlays/Settings/SettingsItemV2.cs b/osu.Game/Overlays/Settings/SettingsItemV2.cs new file mode 100644 index 000000000000..0a3830334e2d --- /dev/null +++ b/osu.Game/Overlays/Settings/SettingsItemV2.cs @@ -0,0 +1,181 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Overlays.Settings +{ + public sealed partial class SettingsItemV2 : CompositeDrawable, ISettingsItem, IConditionalFilterable + { + public readonly IFormControl Control; + + private readonly SettingsRevertToDefaultButton revertButton; + + private readonly BindableBool controlDefault = new BindableBool(true); + private readonly BindableBool controlEnabled = new BindableBool(true); + + /// + /// Whether a revert button should be displayed when the control is modified away from default state. + /// + public bool ShowRevertToDefaultButton { get; init; } = true; + + /// + /// A note to display underneath the setting. + /// + public readonly Bindable Note = new Bindable(); + + public SettingsItemV2(IFormControl control) + { + Control = control; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = SettingsPanel.CONTENT_PADDING, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + revertButton = new SettingsRevertToDefaultButton + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Action = ApplyDefault, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = (Drawable)control, + } + } + }, + new SettingsNote + { + RelativeSizeAxes = Axes.X, + Current = { BindTarget = Note }, + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + controlDefault.Value = Control.IsDefault; + controlEnabled.Value = !Control.IsDisabled; + + controlDefault.BindValueChanged(_ => updateDefaultState()); + controlEnabled.BindValueChanged(_ => updateDefaultState(), true); + FinishTransforms(true); + } + + private void updateDefaultState() + { + bool showRevertButton = !controlDefault.Value && controlEnabled.Value && ShowRevertToDefaultButton; + + if (showRevertButton) + revertButton.Show(); + else + revertButton.Hide(); + } + + protected override void Update() + { + base.Update(); + controlDefault.Value = Control.IsDefault; + controlEnabled.Value = !Control.IsDisabled; + + revertButton.Height = Control.MainDrawHeight; + } + + #region ISettingsItem + + public bool HasClassicDefault => ApplyClassicDefault != null; + + /// + /// If set, this setting is considered as having a "classic" default value, + /// and this is the function for overwriting the control with that value. + /// + public Action? ApplyClassicDefault { get; set; } + + void ISettingsItem.ApplyClassicDefault() => ApplyClassicDefault?.Invoke(Control); + + public void ApplyDefault() + { + if (!Control.IsDisabled) + Control.SetDefault(); + } + + public event Action SettingChanged + { + add => Control.ValueChanged += value; + remove => Control.ValueChanged -= value; + } + + #endregion + + #region Filtering + + public const string CLASSIC_DEFAULT_SEARCH_TERM = @"has-classic-default"; + + public IEnumerable Keywords { get; init; } = Enumerable.Empty(); + + public IEnumerable FilterTerms + { + get + { + var filterTerms = new List(Keywords.Select(k => (LocalisableString)k)); + filterTerms.AddRange(Control.FilterTerms); + + if (HasClassicDefault) + filterTerms.Add(CLASSIC_DEFAULT_SEARCH_TERM); + + return filterTerms; + } + } + + private bool matchingFilter = true; + + public bool MatchingFilter + { + get => matchingFilter; + set + { + bool wasPresent = IsPresent; + + matchingFilter = value; + + if (IsPresent != wasPresent) + Invalidate(Invalidation.Presence); + } + } + + public override bool IsPresent => base.IsPresent && MatchingFilter; + + public bool FilteringActive { get; set; } + + public BindableBool CanBeShown { get; } = new BindableBool(true); + + IBindable IConditionalFilterable.CanBeShown => CanBeShown; + + #endregion + } +} diff --git a/osu.Game/Overlays/Settings/SettingsNote.cs b/osu.Game/Overlays/Settings/SettingsNote.cs new file mode 100644 index 000000000000..be2489a92e90 --- /dev/null +++ b/osu.Game/Overlays/Settings/SettingsNote.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Settings +{ + public sealed partial class SettingsNote : CompositeDrawable + { + public readonly Bindable Current = new Bindable(); + + private Box background = null!; + private OsuTextFlowContainer text = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeDuration = 300; + AutoSizeEasing = Easing.OutQuint; + + InternalChild = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Top = SettingsSection.ITEM_SPACING_V2 }, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + CornerRadius = 5, + CornerExponent = 2.5f, + Masking = true, + Children = new Drawable[] + { + background = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuTextFlowContainer(s => s.Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold)) + { + Padding = new MarginPadding(8), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateDisplay(), true); + FinishTransforms(true); + } + + private void updateDisplay() + { + // Explicitly use ClearTransforms to clear any existing auto-size transform before modifying size / flag. + // TODO: This is dodgy as hell and needs to go. + ClearTransforms(false, @"baseSize"); + ClearTransforms(false, nameof(Height)); + + if (Current.Value == null) + { + AutoSizeAxes = Axes.None; + this.ResizeHeightTo(0, 300, Easing.OutQuint); + this.FadeOut(250, Easing.OutQuint); + return; + } + + AutoSizeAxes = Axes.Y; + this.FadeIn(250, Easing.OutQuint); + + switch (Current.Value.Type) + { + case Type.Informational: + background.Colour = colourProvider.Dark2; + text.Colour = colourProvider.Content2; + break; + + case Type.Warning: + background.Colour = colours.Orange1; + text.Colour = colourProvider.Background5; + break; + + case Type.Critical: + background.Colour = colours.Red1; + text.Colour = colourProvider.Background5; + break; + } + + text.Text = Current.Value.Text; + } + + public record Data(LocalisableString Text, Type Type); + + public enum Type + { + Informational, + Warning, + Critical, + } + } +} diff --git a/osu.Game/Overlays/Settings/SettingsRevertToDefaultButton.cs b/osu.Game/Overlays/Settings/SettingsRevertToDefaultButton.cs new file mode 100644 index 000000000000..fc8b49265688 --- /dev/null +++ b/osu.Game/Overlays/Settings/SettingsRevertToDefaultButton.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; +using osu.Game.Localisation; +using osuTK; + +namespace osu.Game.Overlays.Settings +{ + public partial class SettingsRevertToDefaultButton : OsuClickableContainer + { + public const float WIDTH = 28; + + public float IconSize { get; init; } = 10; + + private Box background = null!; + private SpriteIcon spriteIcon = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + // this is done to ensure a click on this button doesn't trigger focus on a parent element which contains the button. + public override bool AcceptsFocus => true; + + public SettingsRevertToDefaultButton() + { + Width = WIDTH; + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = 5; + CornerExponent = 2.5f; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + }, + spriteIcon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colourProvider.Light1, + Icon = FontAwesome.Solid.Undo, + Margin = new MarginPadding { Left = 12, Right = 5 }, + Size = new Vector2(IconSize), + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Enabled.BindValueChanged(_ => updateDisplay(), true); + } + + public override LocalisableString TooltipText => CommonStrings.RevertToDefault; + + protected override bool OnHover(HoverEvent e) + { + updateDisplay(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateDisplay(); + base.OnHoverLost(e); + } + + public override void Show() + { + this.FadeIn().MoveToX(WIDTH - 10, 200, Easing.OutElasticQuarter); + } + + public override void Hide() + { + this.MoveToX(0, 120, Easing.OutExpo).Then().FadeOut(); + } + + private void updateDisplay() + { + spriteIcon.FadeColour(IsHovered ? colourProvider.Content2 : colourProvider.Light1, 300, Easing.OutQuint); + background.FadeColour(IsHovered ? colourProvider.Background2 : colourProvider.Background3, 300, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Settings/SettingsSection.cs b/osu.Game/Overlays/Settings/SettingsSection.cs index 9602e4373f97..5a57114deb4c 100644 --- a/osu.Game/Overlays/Settings/SettingsSection.cs +++ b/osu.Game/Overlays/Settings/SettingsSection.cs @@ -35,9 +35,10 @@ public abstract partial class SettingsSection : Container, IFilterable public virtual IEnumerable FilterTerms => new[] { Header }; public const int ITEM_SPACING = 14; + public const int ITEM_SPACING_V2 = 4; private const int header_size = 24; - private const int border_size = 4; + private const int border_size = 2; private bool matchingFilter = true; @@ -73,7 +74,7 @@ protected SettingsSection() { Top = 36 }, - Spacing = new Vector2(0, ITEM_SPACING), + Spacing = new Vector2(0, ITEM_SPACING_V2), Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -114,10 +115,7 @@ private void load(OverlayColourProvider colourProvider) { Font = OsuFont.TorusAlternate.With(size: header_size), Text = Header, - Margin = new MarginPadding - { - Horizontal = SettingsPanel.CONTENT_MARGINS - } + Margin = SettingsPanel.CONTENT_PADDING, }, FlowContent } diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index 87772eb18c86..8d7d9844a883 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -14,6 +14,8 @@ namespace osu.Game.Overlays.Settings { public abstract partial class SettingsSubsection : FillFlowContainer, IFilterable { + public const float VERTICAL_PADDING = (header_height - header_font_size) * 0.5f; + protected override Container Content => FlowContent; protected readonly FillFlowContainer FlowContent; @@ -37,9 +39,9 @@ protected SettingsSubsection() FlowContent = new FillFlowContainer { - Margin = new MarginPadding { Top = SettingsSection.ITEM_SPACING }, + Margin = new MarginPadding { Top = SettingsSection.ITEM_SPACING_V2 }, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, SettingsSection.ITEM_SPACING), + Spacing = new Vector2(0, SettingsSection.ITEM_SPACING_V2), RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, }; @@ -51,16 +53,22 @@ protected SettingsSubsection() [BackgroundDependencyLoader] private void load() { - AddRangeInternal(new Drawable[] + AddRangeInternal(new[] { - new OsuSpriteText - { - Text = Header, - Margin = new MarginPadding { Vertical = (header_height - header_font_size) * 0.5f, Horizontal = SettingsPanel.CONTENT_MARGINS }, - Font = OsuFont.GetFont(size: header_font_size), - }, + CreateHeader(), FlowContent }); } + + protected virtual Drawable CreateHeader() + { + return new OsuSpriteText + { + Text = Header, + Font = OsuFont.GetFont(size: header_font_size), + Margin = new MarginPadding { Vertical = VERTICAL_PADDING }, + Padding = SettingsPanel.CONTENT_PADDING, + }; + } } } diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index 9b268c573f40..e6dda398cffb 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -31,6 +31,9 @@ public abstract partial class SettingsPanel : OsuFocusedOverlayContainer { public const float CONTENT_MARGINS = 20; + // extra right padding to give room to the revert-to-default button in settings controls. + public static readonly MarginPadding CONTENT_PADDING = new MarginPadding { Left = 12, Right = 22 }; + public const float TRANSITION_LENGTH = 600; private const float sidebar_width = SettingsSidebar.EXPANDED_WIDTH; @@ -130,8 +133,9 @@ private void load() AutoSizeAxes = Axes.Y, Padding = new MarginPadding { - Vertical = 20, - Horizontal = CONTENT_MARGINS + Vertical = 6, + Left = CONTENT_PADDING.Left, + Right = CONTENT_PADDING.Right, }, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -320,7 +324,7 @@ private void load(OverlayColourProvider colourProvider) { HeaderBackground = new Box { - Colour = colourProvider.Background4, + Colour = colourProvider.Background5, RelativeSizeAxes = Axes.Both }; diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index d82118fa1a5d..9090a294b5d5 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; -using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; @@ -46,6 +45,12 @@ public partial class SettingsToolboxGroup : Container, IExpandable public BindableBool Expanded { get; } = new BindableBool(true); + public Vector2 Spacing + { + get => content.Spacing; + set => content.Spacing = value; + } + private OsuSpriteText headerText = null!; private Container headerContent = null!; @@ -58,6 +63,9 @@ public partial class SettingsToolboxGroup : Container, IExpandable private Drawable? draggedChild; + private bool? lastMouseInBounds; + private bool mouseInBounds => Contains(inputManager.CurrentState.Mouse.Position); + /// /// Create a new instance. /// @@ -137,20 +145,6 @@ protected override void LoadComplete() this.Delay(600).Schedule(updateFadeState); } - protected override bool OnHover(HoverEvent e) - { - updateFadeState(); - updateExpandedState(true); - return false; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateFadeState(); - updateExpandedState(true); - base.OnHoverLost(e); - } - protected override void Update() { base.Update(); @@ -160,10 +154,16 @@ protected override void Update() headerText.Alpha = (float)Interpolation.DampContinuously(headerText.Alpha, headerText.DrawWidth < DrawWidth ? 1 : 0, 40, Time.Elapsed); // Dragged child finished its drag operation. - if (draggedChild != null && inputManager.DraggedDrawable != draggedChild) - { + bool childDragFinished = draggedChild != null && inputManager.DraggedDrawable != draggedChild; + + if (childDragFinished) draggedChild = null; + + if (childDragFinished || lastMouseInBounds != mouseInBounds) + { updateExpandedState(true); + updateFadeState(); + lastMouseInBounds = mouseInBounds; } } @@ -179,7 +179,7 @@ private void updateExpandedState(bool animate) // potentially continuing to get processed while content has changed to autosize. content.ClearTransforms(); - if (Expanded.Value || IsHovered || draggedChild != null) + if (Expanded.Value || mouseInBounds || draggedChild != null) { content.AutoSizeAxes = Axes.Y; content.AutoSizeDuration = animate ? transition_duration : 0; @@ -198,8 +198,8 @@ private void updateFadeState() { const float fade_duration = 500; - background.FadeTo(IsHovered ? 1 : 0.1f, fade_duration, Easing.OutQuint); - expandButton.FadeTo(IsHovered ? 1 : 0, fade_duration, Easing.OutQuint); + background.FadeTo(mouseInBounds ? 1 : 0.1f, fade_duration, Easing.OutQuint); + expandButton.FadeTo(mouseInBounds ? 1 : 0, fade_duration, Easing.OutQuint); } } } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 823456dddd81..193d570a21f3 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -529,7 +529,9 @@ private bool placeComponent(ISerialisableDrawable component, bool applyDefaults } SelectedComponents.Add(component); - SkinSelectionHandler.ApplyClosestAnchorOrigin(drawableComponent); + + if (!component.UsesFixedAnchor) + SkinSelectionHandler.ApplyClosestAnchorOrigin(drawableComponent); return true; } @@ -767,8 +769,9 @@ protected override void Dispose(bool isDisposing) private partial class SkinEditorToast : Toast { public SkinEditorToast(LocalisableString value, string skinDisplayName) - : base(SkinSettingsStrings.SkinLayoutEditor, value, skinDisplayName) + : base(SkinSettingsStrings.SkinLayoutEditor, value) { + ExtraText = skinDisplayName; } } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 83a5d95bb4f2..134bed22afb3 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -28,7 +28,7 @@ using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Users; using osu.Game.Utils; @@ -208,7 +208,7 @@ private void presentGameplay(bool attemptedBeatmapSwitch) mods.Value = mods.Value.Except(invalid).ToArray(); if (replayGeneratingMod != null) - screen.Push(new EndlessPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods))); + screen.Push(new EndlessPlayer(replayGeneratingMod.CreateScoreFromReplayData)); }, new[] { typeof(Player), typeof(SoloSongSelect) }); } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs index f8d5213622f0..52dcadd977dd 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs @@ -12,9 +12,8 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Screens; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osuTK; -using SongSelect = osu.Game.Screens.Select.SongSelect; namespace osu.Game.Overlays.SkinEditor { diff --git a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs index bd1c94484718..e1277f6e2991 100644 --- a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; using osuTK; namespace osu.Game.Overlays.Toolbar @@ -76,7 +77,7 @@ private void load(OsuColour colours) { new OsuSpriteText { - Text = "running", + Text = ToolbarStrings.TimeRunning, Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold), }, gameTime = new OsuSpriteText diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 5b75b8419c8d..f40f00523e12 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -1,23 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; @@ -36,20 +33,7 @@ public void SetIcon(Drawable icon) IconContainer.Show(); } - [Resolved] - private ReadableKeyCombinationProvider keyCombinationProvider { get; set; } = null!; - - public void SetIcon(IconUsage icon) => - SetIcon(new SpriteIcon - { - Icon = icon, - }); - - public LocalisableString Text - { - get => DrawableText.Text; - set => DrawableText.Text = value; - } + public void SetIcon(IconUsage icon) => SetIcon(new SpriteIcon { Icon = icon }); public LocalisableString TooltipMain { @@ -67,21 +51,16 @@ public LocalisableString TooltipSub protected readonly Container ButtonContent; protected ConstrainedIconContainer IconContainer; - protected SpriteText DrawableText; protected Box HoverBackground; private readonly Box flashBackground; private readonly FillFlowContainer tooltipContainer; private readonly SpriteText tooltip1; private readonly SpriteText tooltip2; - private readonly SpriteText keyBindingTooltip; protected FillFlowContainer Flow; protected readonly Container BackgroundContent; - private IDisposable? realmSubscription; - - [Resolved] - private RealmAccess realm { get; set; } = null!; + private readonly FillFlowContainer subTooltipFlow; protected ToolbarButton() { @@ -124,7 +103,6 @@ protected ToolbarButton() Flow = new FillFlowContainer { Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Padding = new MarginPadding { Left = Toolbar.HEIGHT / 2, Right = Toolbar.HEIGHT / 2 }, @@ -139,11 +117,6 @@ protected ToolbarButton() Size = new Vector2(20), Alpha = 0, }, - DrawableText = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, }, }, }, @@ -165,16 +138,15 @@ protected ToolbarButton() Shadow = true, Font = OsuFont.GetFont(size: 22, weight: FontWeight.Bold), }, - new FillFlowContainer + subTooltipFlow = new FillFlowContainer { AutoSizeAxes = Axes.Both, Anchor = TooltipAnchor, Origin = TooltipAnchor, Direction = FillDirection.Horizontal, - Children = new[] + Children = new Drawable[] { tooltip2 = new OsuSpriteText { Shadow = true }, - keyBindingTooltip = new OsuSpriteText { Shadow = true } } } } @@ -187,8 +159,13 @@ private void load() { if (Hotkey != null) { - realmSubscription = realm.SubscribeToPropertyChanged(r => r.All().FirstOrDefault(rkb => rkb.RulesetName == null && rkb.ActionInt == (int)Hotkey.Value), - kb => kb.KeyCombinationString, updateKeyBindingTooltip); + subTooltipFlow.Add(new HotkeyDisplay + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Hotkey = new Hotkey(Hotkey.Value), + Margin = new MarginPadding { Left = 3 }, + }); } } @@ -203,16 +180,16 @@ protected override bool OnClick(ClickEvent e) protected override bool OnHover(HoverEvent e) { - HoverBackground.FadeIn(200); - tooltipContainer.FadeIn(100); + HoverBackground.FadeIn(300, Easing.OutQuint); + tooltipContainer.FadeIn(200, Easing.OutQuint); return true; } protected override void OnHoverLost(HoverLostEvent e) { - HoverBackground.FadeOut(200); - tooltipContainer.FadeOut(100); + HoverBackground.FadeOut(200, Easing.Out); + tooltipContainer.FadeOut(100, Easing.Out); } public bool OnPressed(KeyBindingPressEvent e) @@ -229,22 +206,6 @@ public bool OnPressed(KeyBindingPressEvent e) public void OnReleased(KeyBindingReleaseEvent e) { } - - private void updateKeyBindingTooltip(string keyCombination) - { - string keyBindingString = keyCombinationProvider.GetReadableString(keyCombination); - - keyBindingTooltip.Text = !string.IsNullOrEmpty(keyBindingString) - ? $" ({keyBindingString})" - : string.Empty; - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - realmSubscription?.Dispose(); - } } public partial class OpaqueBackground : Container diff --git a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs index 51b95b7d3287..e6ffc1ad5ef7 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs @@ -43,7 +43,7 @@ private void load(NowPlayingOverlay music) Origin = Anchor.CentreLeft, Width = 3f, Height = IconContainer.Height, - Margin = new MarginPadding { Horizontal = 2.5f }, + Margin = new MarginPadding { Left = 7.5f, Right = 2.5f }, Masking = true, Children = new[] { diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index 787c525566bb..d8350ce4519d 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Online.API; @@ -32,6 +33,8 @@ public partial class ToolbarUserButton : ToolbarOverlayToggleButton private IBindable apiState = null!; + private OsuSpriteText usernameText = null!; + public ToolbarUserButton() { ButtonContent.AutoSizeAxes = Axes.X; @@ -40,51 +43,58 @@ public ToolbarUserButton() [BackgroundDependencyLoader] private void load(OsuColour colours, IAPIProvider api, LoginOverlay? login) { - Flow.Add(new Container + Flow.AddRange(new Drawable[] { - Masking = true, - CornerRadius = 4, - Size = new Vector2(32), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - EdgeEffect = new EdgeEffectParameters + usernameText = new OsuSpriteText { - Type = EdgeEffectType.Shadow, - Radius = 4, - Colour = Color4.Black.Opacity(0.1f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 5 }, }, - Children = new Drawable[] + new Container { - avatar = new UpdateableAvatar(isInteractive: false) - { - RelativeSizeAxes = Axes.Both, - }, - spinner = new LoadingLayer(dimBackground: true, withBox: false, blockInput: false) + Masking = true, + CornerRadius = 4, + Size = new Vector2(32), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + EdgeEffect = new EdgeEffectParameters { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, + Type = EdgeEffectType.Shadow, + Radius = 4, + Colour = Color4.Black.Opacity(0.1f), }, - failingIcon = new SpriteIcon + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Alpha = 0, - Size = new Vector2(0.3f), - Icon = FontAwesome.Solid.ExclamationTriangle, - RelativeSizeAxes = Axes.Both, - Colour = colours.YellowLight, - }, + avatar = new UpdateableAvatar(isInteractive: false) + { + RelativeSizeAxes = Axes.Both, + }, + spinner = new LoadingLayer(dimBackground: true, withBox: false) + { + BlockPositionalInput = false, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, + failingIcon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Size = new Vector2(0.3f), + Icon = FontAwesome.Solid.ExclamationTriangle, + RelativeSizeAxes = Axes.Both, + Colour = colours.YellowLight, + }, + } + }, + new TransientUserStatisticsUpdateDisplay + { + Alpha = 0, } }); - Flow.Add(new TransientUserStatisticsUpdateDisplay - { - Alpha = 0 - }); - Flow.AutoSizeEasing = Easing.OutQuint; - Flow.AutoSizeDuration = 250; - apiState = api.State.GetBoundCopy(); apiState.BindValueChanged(onlineStateChanged, true); @@ -96,7 +106,7 @@ private void load(OsuColour colours, IAPIProvider api, LoginOverlay? login) private void userChanged(ValueChangedEvent user) => Schedule(() => { - Text = user.NewValue.Username; + usernameText.Text = user.NewValue.Username; avatar.User = user.NewValue; }); diff --git a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs index 85b7358d2d28..e5df7a65dc0a 100644 --- a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs +++ b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs @@ -26,12 +26,12 @@ public partial class TransientUserStatisticsUpdateDisplay : CompositeDrawable private Statistic globalRank = null!; private Statistic pp = null!; + private ScheduledDelegate? shrinkDelegate; + [BackgroundDependencyLoader] private void load(UserStatisticsWatcher? userStatisticsWatcher) { RelativeSizeAxes = Axes.Y; - AutoSizeAxes = Axes.X; - Alpha = 0; InternalChild = new FillFlowContainer { @@ -40,7 +40,7 @@ private void load(UserStatisticsWatcher? userStatisticsWatcher) Padding = new MarginPadding { Horizontal = 10 }, Spacing = new Vector2(10), Direction = FillDirection.Horizontal, - Children = new Drawable[] + Children = new[] { globalRank = new Statistic(UsersStrings.ShowRankGlobalSimple, @"#", Comparer.Create((before, after) => before - after)), pp = new Statistic(RankingsStrings.StatPerformance, string.Empty, Comparer.Create((before, after) => Math.Sign(after - before))), @@ -71,8 +71,7 @@ protected override void LoadComplete() return; FinishTransforms(true); - - this.FadeIn(500, Easing.OutQuint); + shrinkDelegate?.Cancel(); if (update.After.GlobalRank != null) { @@ -90,7 +89,21 @@ protected override void LoadComplete() pp.Display(before, delta, after); } - this.Delay(5000).FadeOut(500, Easing.OutQuint); + this.FadeIn(500, Easing.OutQuint); + + AutoSizeAxes = Axes.X; + AutoSizeDuration = 500; + AutoSizeEasing = Easing.OutQuint; + + using (BeginDelayedSequence(5000)) + { + this.FadeOut(500, Easing.OutQuint); + shrinkDelegate = Schedule(() => + { + AutoSizeAxes = Axes.None; + this.ResizeWidthTo(0, 500, Easing.OutQuint); + }); + } }); } diff --git a/osu.Game/Overlays/Volume/MasterVolumeMeter.cs b/osu.Game/Overlays/Volume/MasterVolumeMeter.cs index 951a6d53b187..fd3d410321f5 100644 --- a/osu.Game/Overlays/Volume/MasterVolumeMeter.cs +++ b/osu.Game/Overlays/Volume/MasterVolumeMeter.cs @@ -5,6 +5,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osuTK.Graphics; namespace osu.Game.Overlays.Volume @@ -20,7 +21,7 @@ public partial class MasterVolumeMeter : VolumeMeter [Resolved] private VolumeOverlay volumeOverlay { get; set; } = null!; - public MasterVolumeMeter(string name, float circleSize, Color4 meterColour) + public MasterVolumeMeter(LocalisableString name, float circleSize, Color4 meterColour) : base(name, circleSize, meterColour) { } diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index 9e0c59938606..e75ee140675c 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -19,6 +19,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Graphics; @@ -42,7 +43,7 @@ public partial class VolumeMeter : Container, IStateful protected readonly float CircleSize; private readonly Color4 meterColour; - private readonly string name; + private readonly LocalisableString name; private OsuSpriteText text; private BufferedContainer maxGlow; @@ -75,7 +76,7 @@ public SelectionState State private const float transition_length = 500; - public VolumeMeter(string name, float circleSize, Color4 meterColour) + public VolumeMeter(LocalisableString name, float circleSize, Color4 meterColour) { CircleSize = circleSize; this.meterColour = meterColour; @@ -214,6 +215,7 @@ private void load(OsuColour colours, AudioManager audio) new Container { Size = LABEL_SIZE, + AutoSizeAxes = Axes.X, CornerRadius = 10, Masking = true, Margin = new MarginPadding { Left = CircleSize + 10 }, @@ -228,6 +230,10 @@ private void load(OsuColour colours, AudioManager audio) }, new OsuSpriteText { + Margin = new MarginPadding + { + Horizontal = 32, + }, Anchor = Anchor.Centre, Origin = Anchor.Centre, Font = OsuFont.GetFont(weight: FontWeight.Bold), @@ -321,6 +327,8 @@ public double Volume private float dragDelta; + protected override bool OnMouseDown(MouseDownEvent e) => true; // handle to prevent drawables behind from potentially receiving the mouse down + protected override bool OnDragStart(DragStartEvent e) { dragDelta = 0; diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs index bb2ad6069522..8a656f4f2456 100644 --- a/osu.Game/Overlays/VolumeOverlay.cs +++ b/osu.Game/Overlays/VolumeOverlay.cs @@ -6,6 +6,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -15,6 +16,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Overlays.Volume; using osuTK; using osuTK.Graphics; @@ -67,9 +69,9 @@ private void load(AudioManager audio, OsuColour colours) Spacing = new Vector2(0, offset), Children = new[] { - volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker), - volumeMeterMaster = new MasterVolumeMeter("MASTER", 150, colours.PinkDarker) { IsMuted = { BindTarget = IsMuted }, }, - volumeMeterMusic = new VolumeMeter("MUSIC", 125, colours.BlueDarker), + volumeMeterEffect = new VolumeMeter(AudioSettingsStrings.EffectVolume.ToUpper(), 125, colours.BlueDarker), + volumeMeterMaster = new MasterVolumeMeter(AudioSettingsStrings.MasterVolume.ToUpper(), 150, colours.PinkDarker) { IsMuted = { BindTarget = IsMuted }, }, + volumeMeterMusic = new VolumeMeter(AudioSettingsStrings.MusicVolume.ToUpper(), 125, colours.BlueDarker), } }, }, diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs index 1ab35b197250..ee00843317b3 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using Markdig.Extensions.Yaml; +using Markdig.Helpers; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -27,9 +29,16 @@ public WikiNoticeContainer(YamlFrontMatterBlock yamlFrontMatterBlock) Direction = FillDirection.Vertical; Spacing = new Vector2(10); - foreach (object line in yamlFrontMatterBlock.Lines) + foreach (StringLine line in yamlFrontMatterBlock.Lines) { - switch (line.ToString()) + // Commonly, comments are added to the end of lines. + // There are cases of '#' used inside frontmatter, ie for url content, so matching with the prefix space is important. + string cleaned = line.ToString().Split(" #").First().Trim(); + + // Exact text matching should hopefully be enough as a certain degree of linting is applied at + // the wiki side. Note that this will ensure cases of the whole line being commented out are not actioned + // on, covering cases not caught by the above check. + switch (cleaned) { case @"outdated: true": isOutdated = true; diff --git a/osu.Game/Overlays/WizardOverlay.cs b/osu.Game/Overlays/WizardOverlay.cs index 3cc403dbffef..a75f1aff3c2d 100644 --- a/osu.Game/Overlays/WizardOverlay.cs +++ b/osu.Game/Overlays/WizardOverlay.cs @@ -245,10 +245,9 @@ private void load(OverlayColourProvider colourProvider) Padding = new MarginPadding { Right = OsuGame.SCREEN_EDGE_MARGIN }; - InternalChild = NextButton = new ShearedButton(0) + InternalChild = NextButton = new ShearedButton { RelativeSizeAxes = Axes.X, - Width = 1, Text = FirstRunSetupOverlayStrings.GetStarted, DarkerColour = colourProvider.Colour3, LighterColour = colourProvider.Colour2, diff --git a/osu.Game/PerformFromMenuRunner.cs b/osu.Game/PerformFromMenuRunner.cs index 21beadf36639..006430c4274d 100644 --- a/osu.Game/PerformFromMenuRunner.cs +++ b/osu.Game/PerformFromMenuRunner.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Threading; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Notifications; @@ -165,7 +166,11 @@ private bool checkForDialog(IScreen current) // the last dialog encountered has been dismissed but the screen has not changed, abort. Cancel(); - notifications.Post(new SimpleNotification { Text = @"An action was interrupted due to a dialog being displayed." }); + notifications.Post(new SimpleNotification + { + Text = NotificationsStrings.ActionInterruptedByDialog + }); + return true; } diff --git a/osu.Game/Properties/AssemblyInfo.cs b/osu.Game/Properties/AssemblyInfo.cs index be430a0fe4f2..75e3ff8fd0ee 100644 --- a/osu.Game/Properties/AssemblyInfo.cs +++ b/osu.Game/Properties/AssemblyInfo.cs @@ -11,6 +11,7 @@ [assembly: InternalsVisibleTo("osu.Game.Tests.Dynamic")] [assembly: InternalsVisibleTo("osu.Game.Tests.iOS")] [assembly: InternalsVisibleTo("osu.Game.Tests.Android")] +[assembly: InternalsVisibleTo("osu.Game.Tournament.Tests")] // intended for Moq usage [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 5e431dc35728..c98e2137ac10 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -33,6 +33,8 @@ public class DifficultyAttributes protected const int ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE = 41; protected const int ATTRIB_ID_RHYTHM_DIFFICULTY = 43; protected const int ATTRIB_ID_CONSISTENCY_FACTOR = 45; + protected const int ATTRIB_ID_READING = 47; + protected const int ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT = 49; /// /// The mods which were applied to the beatmap. diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 7acfbe651fe8..ec94bd704b6c 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -34,7 +34,6 @@ public abstract class DifficultyCalculator protected readonly IWorkingBeatmap WorkingBeatmap; private Mod[] playableMods; - private double clockRate; private readonly IRulesetInfo ruleset; @@ -74,10 +73,10 @@ public DifficultyAttributes Calculate([NotNull] IEnumerable mods, Cancellat // ReSharper disable once PossiblyMistakenUseOfCancellationToken preProcess(mods, cancellationToken); - var skills = CreateSkills(Beatmap, playableMods, clockRate); + var skills = CreateSkills(Beatmap, playableMods); if (!Beatmap.HitObjects.Any()) - return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); + return CreateDifficultyAttributes(Beatmap, playableMods, skills); foreach (var hitObject in getDifficultyHitObjects()) { @@ -88,7 +87,7 @@ public DifficultyAttributes Calculate([NotNull] IEnumerable mods, Cancellat } } - return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); + return CreateDifficultyAttributes(Beatmap, playableMods, skills); } /// @@ -121,7 +120,7 @@ public List CalculateTimed([NotNull] IEnumerable if (!Beatmap.HitObjects.Any()) return attribs; - var skills = CreateSkills(Beatmap, playableMods, clockRate); + var skills = CreateSkills(Beatmap, playableMods); var progressiveBeatmap = new ProgressiveCalculationBeatmap(Beatmap); var difficultyObjects = getDifficultyHitObjects().ToArray(); @@ -142,7 +141,7 @@ public List CalculateTimed([NotNull] IEnumerable currentIndex++; } - attribs.Add(new TimedDifficultyAttributes(obj.GetEndTime(), CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); + attribs.Add(new TimedDifficultyAttributes(obj.GetEndTime(), CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills))); } return attribs; @@ -174,7 +173,7 @@ public IEnumerable CalculateAllLegacyCombinations(Cancella /// /// Retrieves the s to calculate against. /// - private IEnumerable getDifficultyHitObjects() => SortObjects(CreateDifficultyHitObjects(Beatmap, clockRate)); + private IEnumerable getDifficultyHitObjects() => SortObjects(CreateDifficultyHitObjects(Beatmap, playableMods)); /// /// Performs required tasks before every calculation. @@ -185,8 +184,6 @@ private void preProcess([NotNull] IEnumerable mods, CancellationToken cance { playableMods = mods.Select(m => m.DeepClone()).ToArray(); Beatmap = WorkingBeatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); - - clockRate = ModUtils.CalculateRateWithMods(playableMods); } /// @@ -277,16 +274,15 @@ static IEnumerable createDifficultyAdjustmentModCombinations(ReadOnlyMemory /// This may differ from in the case of timed calculation. /// The s that difficulty was calculated with. /// The skills which processed the beatmap. - /// The rate at which the gameplay clock is run at. - protected abstract DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate); + protected abstract DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills); /// /// Enumerates s to be processed from s in the . /// /// The providing the s to enumerate. - /// The rate at which the gameplay clock is run at. + /// Mods to create difficulty objects with. /// The enumerated s. - protected abstract IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate); + protected abstract IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods); /// /// Creates the s to calculate the difficulty of an . @@ -294,9 +290,8 @@ static IEnumerable createDifficultyAdjustmentModCombinations(ReadOnlyMemory /// The whose difficulty will be calculated. /// This may differ from in the case of timed calculation. /// Mods to calculate difficulty with. - /// Clockrate to calculate difficulty with. /// The s. - protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate); + protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods); /// /// Used to calculate timed difficulty attributes, where only a subset of hitobjects should be visible at any point in time. diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index 9785865192bd..1db231945636 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Difficulty.Preprocessing { @@ -45,6 +46,11 @@ public class DifficultyHitObject /// public readonly double EndTime; + /// + /// Beatmap playback rate. + /// + public readonly double ClockRate; + /// /// Creates a new . /// @@ -62,6 +68,7 @@ public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clo DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate; StartTime = hitObject.StartTime / clockRate; EndTime = hitObject.GetEndTime() / clockRate; + ClockRate = clockRate; } public DifficultyHitObject Previous(int backwardsIndex) @@ -75,5 +82,26 @@ public DifficultyHitObject Next(int forwardsIndex) int index = Index + (forwardsIndex + 1); return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : default; } + + /// + /// Retrieves the full hit window for a . + /// + public virtual double HitWindow(HitResult hitResult) + { + // Try to get HitWindows from nested hit objects + // This is important for objects such as Slider in osu! where the object itself has HitWindows set to Empty, but the nested SliderHead has proper hit windows + if (BaseObject.HitWindows == HitWindows.Empty) + { + foreach (var nestedHitObject in BaseObject.NestedHitObjects) + { + if (nestedHitObject.HitWindows == HitWindows.Empty) + continue; + + return 2 * nestedHitObject.HitWindows.WindowFor(hitResult) / ClockRate; + } + } + + return 2 * BaseObject.HitWindows.WindowFor(hitResult) / ClockRate; + } } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs new file mode 100644 index 000000000000..cdd6048b8610 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs @@ -0,0 +1,105 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + public abstract class HarmonicSkill : Skill + { + /// + /// The sum of note weights, calculated during summation. + /// Required for any calculations which need to normalise difficulty value. + /// + protected double NoteWeightSum; + + /// + /// Scaling factor applied as HarmonicScale / (1 + index) during weight calculations. + /// A higher value will increase the influence of the hardest object difficulties during summation. + /// + protected virtual double HarmonicScale => 1.0; + + /// + /// Exponent that controls the rate of which decay increases as the index increases. + /// Values closer to 1 decay faster whilst lower values give more weight to lower object difficulties. + /// + protected virtual double DecayExponent => 0.9; + + protected HarmonicSkill(Mod[] mods) + : base(mods) + { + } + + /// + /// Returns the difficulty value of the current . This value is calculated with or without respect to previous objects. + /// + protected abstract double ObjectDifficultyOf(DifficultyHitObject current); + + protected sealed override double ProcessInternal(DifficultyHitObject current) + => ObjectDifficultyOf(current); + + /// + /// Transforms the object difficulties specifically for final difficulty summation. + /// This can be used to decrease weight of certain notes based on a skill-specific criteria. + /// + protected virtual void ApplyDifficultyTransformation(double[] difficulties) + { + } + + public override double DifficultyValue() + { + if (ObjectDifficulties.Count == 0) + return 0; + + // Notes with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These notes will not contribute to the difficulty. + double[] difficulties = ObjectDifficulties.Where(p => p > 0).ToArray(); + + if (difficulties.Length == 0) + return 0; + + ApplyDifficultyTransformation(difficulties); + + double difficulty = 0; + int index = 0; + + foreach (double note in difficulties.OrderDescending()) + { + // Use a harmonic sum that considers each note of the map according to a predefined weight. + double weight = (1 + (HarmonicScale / (1 + index))) / (Math.Pow(index, DecayExponent) + 1 + (HarmonicScale / (1 + index))); + + NoteWeightSum += weight; + + difficulty += note * weight; + index += 1; + } + + return difficulty; + } + + /// + /// Calculates the number of object difficulties weighted against the top object difficulty. + /// + public virtual double CountTopWeightedObjectDifficulties(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + if (NoteWeightSum == 0) + return 0.0; + + double consistentTopNote = difficultyValue / NoteWeightSum; // What would the top difficulty be if all object difficulties were identical + + if (consistentTopNote == 0) + return 0; + + return ObjectDifficulties.Sum(d => DifficultyCalculationUtils.Logistic(d / consistentTopNote, 0.88, 10, 1.1)); + } + + public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); + } +} diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 8b8892113b8a..cf45104c942c 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -20,6 +20,11 @@ public abstract class Skill /// protected IReadOnlyList Mods => mods; + /// + /// List of calculated per-object difficulties, populated by Process + /// + protected readonly List ObjectDifficulties = new List(); + private readonly Mod[] mods; protected Skill(Mod[] mods) @@ -31,11 +36,19 @@ protected Skill(Mod[] mods) /// Process a . /// /// The to process. - public abstract void Process(DifficultyHitObject current); + public void Process(DifficultyHitObject current) + { + double difficultyValue = ProcessInternal(current); + ObjectDifficulties.Add(difficultyValue); + } + + protected abstract double ProcessInternal(DifficultyHitObject current); /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// public abstract double DifficultyValue(); + + public IReadOnlyList GetObjectDifficulties() => ObjectDifficulties; } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index b6272bf56b85..f066be0ec750 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -29,7 +29,6 @@ public abstract class StrainSkill : Skill private double currentSectionEnd; private readonly List strainPeaks = new List(); - protected readonly List ObjectStrains = new List(); // Store individual strains protected StrainSkill(Mod[] mods) : base(mods) @@ -44,7 +43,7 @@ protected StrainSkill(Mod[] mods) /// /// Process a and update current strain values accordingly. /// - public sealed override void Process(DifficultyHitObject current) + protected sealed override double ProcessInternal(DifficultyHitObject current) { // The first object doesn't generate a strain, so we begin with an incremented section end if (current.Index == 0) @@ -60,26 +59,25 @@ public sealed override void Process(DifficultyHitObject current) double strain = StrainValueAt(current); currentSectionPeak = Math.Max(strain, currentSectionPeak); - // Store the strain value for the object - ObjectStrains.Add(strain); + return strain; } /// /// Calculates the number of strains weighted against the top strain. /// The result is scaled by clock rate as it affects the total number of strains. /// - public virtual double CountTopWeightedStrains() + public virtual double CountTopWeightedStrains(double difficultyValue) { - if (ObjectStrains.Count == 0) + if (ObjectDifficulties.Count == 0) return 0.0; - double consistentTopStrain = DifficultyValue() / 10; // What would the top strain be if all strain values were identical + double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical if (consistentTopStrain == 0) - return ObjectStrains.Count; + return ObjectDifficulties.Count; // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); } /// @@ -116,8 +114,6 @@ private void startNewSectionFrom(double time, DifficultyHitObject current) /// public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(currentSectionPeak); - public IEnumerable GetObjectStrains() => ObjectStrains; - /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// diff --git a/osu.Game/Rulesets/Difficulty/Skills/TimeSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/TimeSkill.cs new file mode 100644 index 000000000000..310646a8baba --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/TimeSkill.cs @@ -0,0 +1,219 @@ +// 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.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + public abstract class TimeSkill : Skill + { + protected TimeSkill(Mod[] mods) + : base(mods) + { + } + + private const double ms_to_minutes = 1.0 / 60000.0; + + // FC time specific constants + private const double time_threshold_minutes = 24; + private const double max_delta_time = 5000; + private const double retry_cooldown_time = 60000; + + // Bin specific constants + private const double bin_threshold_note_count = difficulty_bin_count * time_bin_count; + private const int difficulty_bin_count = 8; + private const int time_bin_count = 16; + + private const double epsilon = 1e-4; + + private readonly List times = new List(); + + /// + /// Returns the strain value at . This value is calculated with or without respect to previous objects. + /// + protected abstract double StrainValueAt(DifficultyHitObject current); + + protected override double ProcessInternal(DifficultyHitObject current) + { + times.Add(current.Index == 0 + ? retry_cooldown_time + Math.Min(current.DeltaTime, max_delta_time) + : times.Last() + Math.Min(current.DeltaTime, max_delta_time)); + + return StrainValueAt(current); + } + + protected abstract double HitProbability(double skill, double difficulty); + + public override double DifficultyValue() + { + if (ObjectDifficulties.Count == 0 || ObjectDifficulties.Max() <= epsilon) + return 0; + + // We only initialize bins if we have enough notes to use them. + List? binList = null; + + if (ObjectDifficulties.Count > bin_threshold_note_count) + { + binList = Bin.CreateBins(ObjectDifficulties, times, difficulty_bin_count, time_bin_count); + } + + // Lower bound and upper bound are generally unimportant + return RootFinding.FindRootExpand(skill => timeSpentRetryingAtSkill(skill, binList) - time_threshold_minutes, 0, 10); + } + + private double timeSpentRetryingAtSkill(double skill, List? binList = null) + { + if (skill <= 0) return double.PositiveInfinity; + + double timeSpentRetrying = 0; + double hitProbabilityProduct = 1; + + // We use bins, falling back to exact difficulty calculation if not available. + if (binList is not null) + { + for (int n = binList.Count - 1; n >= 0; n--) + { + double deltaTime = n > 0 ? binList[n].Time - binList[n - 1].Time : binList[n].Time; + + hitProbabilityProduct *= Math.Pow(HitProbability(skill, binList[n].Difficulty), binList[n].NoteCount); + timeSpentRetrying += hitProbabilityProduct > 0 ? deltaTime / hitProbabilityProduct - deltaTime : double.PositiveInfinity; + } + } + else + { + for (int n = ObjectDifficulties.Count - 1; n >= 0; n--) + { + double deltaTime = n > 0 ? times[n] - times[n - 1] : times[n]; + + hitProbabilityProduct *= HitProbability(skill, ObjectDifficulties[n]); + timeSpentRetrying += hitProbabilityProduct > 0 ? deltaTime / hitProbabilityProduct - deltaTime : double.PositiveInfinity; + } + } + + return timeSpentRetrying * ms_to_minutes; + } + + /// + /// The coefficients of a quartic fitted to the miss counts at each skill level. + /// + /// The coefficients for our penalty polynomial. + public double[] GetMissPenaltyCoefficients() + { + Dictionary missCounts = new Dictionary(); + + // If there are no notes, we just return a zero-polynomial. + if (ObjectDifficulties.Count == 0 || ObjectDifficulties.Max() == 0) + return Array.Empty(); + + double fcSkill = DifficultyValue(); + + // We only initialize bins if we have enough notes to use them. + List? binList = null; + + if (ObjectDifficulties.Count > bin_threshold_note_count) + { + binList = Bin.CreateBins(ObjectDifficulties, times, difficulty_bin_count, time_bin_count); + } + + foreach (double skillProportion in PolynomialPenaltyUtils.SKILL_PROPORTIONS) + { + if (skillProportion == 1) + { + missCounts[skillProportion] = 0; + continue; + } + + double penalizedSkill = fcSkill * skillProportion; + + // We take the log to squash miss counts, which have large absolute value differences, but low relative differences, into a straighter line for the polynomial. + missCounts[skillProportion] = Math.Log(getMissCountAtSkill(penalizedSkill, binList) + 1); + } + + return PolynomialPenaltyUtils.GetPenaltyCoefficients(missCounts); + } + + /// + /// Find the lowest misscount that a player with the provided would likely achieve within 12 minutes of retrying. + /// + private double getMissCountAtSkill(double skill, List? binList = null) + { + double maxDiff = ObjectDifficulties.Max(); + + if (maxDiff == 0) + return 0; + if (skill <= 0) + return ObjectDifficulties.Count; + + IterativePoissonBinomial poiBin = new IterativePoissonBinomial(); + + return Math.Max(0, RootFinding.FindRootExpand(x => retryTimeRequiredToObtainMissCount(x) - time_threshold_minutes, -50, 1000, accuracy: 0.01)); + + double retryTimeRequiredToObtainMissCount(double missCount) + { + poiBin.Reset(); + double timeSpentRetrying = 0; + + if (binList is not null) + { + for (int n = binList.Count - 1; n >= 0; n--) + { + double deltaTime = n > 0 ? binList[n].Time - binList[n - 1].Time : binList[n].Time; + double missProbability = 1 - HitProbability(skill, binList[n].Difficulty); + + // Add this bin's probabilities to track cumulative miss distribution from here to end + poiBin.AddBinnedProbabilities(missProbability, binList[n].NoteCount); + + // Probability of achieving less than this missCount from this point until map end + double missCountProb = poiBin.Cdf(missCount); + + // deltaTime divided by missCountProb = expected total plays of this segment + // Subtract deltaTime to get only the retry time (which excludes the success run) + timeSpentRetrying += missCountProb > 0 ? deltaTime / missCountProb - deltaTime : double.PositiveInfinity; + } + } + else + { + // Same calculation but for individual notes. + for (int n = ObjectDifficulties.Count - 1; n >= 0; n--) + { + double deltaTime = n > 0 ? times[n] - times[n - 1] : times[n]; + double missProbability = 1 - HitProbability(skill, ObjectDifficulties[n]); + poiBin.AddProbability(missProbability); + + double missCountProb = poiBin.Cdf(missCount); + timeSpentRetrying += missCountProb > 0 ? deltaTime / missCountProb - deltaTime : double.PositiveInfinity; + } + } + + return timeSpentRetrying * ms_to_minutes; + } + } + + /// + /// Calculates the number of strains weighted against the top strain. + /// The result is scaled by clock rate as it affects the total number of strains. + /// + public virtual double CountTopWeightedStrains(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + // What would the top strain be if all strain values were identical. + // We don't have decay weight in FC time, so we just use the old live one of 0.95. + double consistentTopStrain = difficultyValue * (1 - 0.95); + + if (consistentTopStrain == 0) + return ObjectDifficulties.Count; + + // Use a weighted sum of all strains. Constants are arbitrary and give nice values + return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + } + + public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0); + } +} diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs new file mode 100644 index 000000000000..a45af7453983 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -0,0 +1,277 @@ +// 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.Extensions; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + /// + /// Similar to , but instead of strains having a fixed length, strains can be any length. + /// A new is created for each . + /// + public abstract class VariableLengthStrainSkill : Skill + { + /// + /// The weight by which each strain value decays. + /// + protected virtual double DecayWeight => 0.9; + + /// + /// The maximum length of each strain section. + /// + protected virtual int MaxSectionLength => 400; + + private double currentSectionPeak; // We also keep track of the peak strain in the current section. + private double currentSectionBegin; + private double currentSectionEnd; + + /// + /// The number of `MaxSectionLength` sections calculated such that enough of the difficulty value is preserved. + /// WARNING: This should be overridden if strains are ever used outside of , + /// or if is overridden to not use the default geometric sum. This should be removed + /// in the future when a better memory-saving technique is implemented. + /// + private double maxStoredSections => 11 / (1 - DecayWeight); + + private readonly List strainPeaks = new List(); + + private double totalLength; + + /// + /// Stores previous strains so that, if a high difficulty hit object is followed by a lower + /// difficulty hit object, the high difficulty hit object gets a full strain instead of being cut short. + /// + private readonly List<(double StrainValue, double StartTime)> queuedStrains = new List<(double, double)>(); + + protected VariableLengthStrainSkill(Mod[] mods) + : base(mods) + { + } + + /// + /// Returns the strain value at . This value is calculated with or without respect to previous objects. + /// + protected abstract double StrainValueAt(DifficultyHitObject current); + + /// + /// Process a and update current strain values accordingly. + /// + protected sealed override double ProcessInternal(DifficultyHitObject current) + { + // If we're on the first object, set up the first section to end `MaxSectionLength` after it. + if (current.Index == 0) + { + currentSectionBegin = current.StartTime; + currentSectionEnd = currentSectionBegin + MaxSectionLength; + + // No work is required for first object after calculating difficulty + currentSectionPeak = StrainValueAt(current); + return currentSectionPeak; + } + + backfillPeaks(current); + + double currentStrain = StrainValueAt(current); + + // If the current strain is larger than the current peak, begin a new peak + // Otherwise, add the current strain to the queue + if (currentStrain > currentSectionPeak) + { + // Clear the queue since none of the strains inside of it will be contributing to the difficulty. + queuedStrains.Clear(); + + // End the current section with the new peak + saveCurrentPeak(current.StartTime - currentSectionBegin); + + // Set up the new section to start at the current object with the current strain + currentSectionBegin = current.StartTime; + currentSectionEnd = currentSectionBegin + MaxSectionLength; + currentSectionPeak = currentStrain; + } + else + { + // Empty the queue of smaller elements as they won't be relevant to difficulty + while (queuedStrains.Count > 0 && queuedStrains[^1].StrainValue < currentStrain) + queuedStrains.RemoveAt(queuedStrains.Count - 1); + + queuedStrains.Add((currentStrain, current.StartTime)); + } + + return currentStrain; + } + + /// + /// Fills the space between the end of the current section and the current object, if there is any. + /// + /// The object who's is backfilled to. + private void backfillPeaks(DifficultyHitObject current) + { + // If the current object starts after the current section ends + // then we want to start a new section without any harsh drop-off. + // If we have previous strains that influence the current difficulty we will prioritise those first. + // Otherwise, start with the current object's initial strain. + while (current.StartTime > currentSectionEnd) + { + // Save the current peak, marking the end of the section. + saveCurrentPeak(currentSectionEnd - currentSectionBegin); + currentSectionBegin = currentSectionEnd; + + // If we have any strains queued, then we will use those until the object falls into the new section. + if (queuedStrains.Count > 0) + { + (double strain, double startTime) = queuedStrains[0]; + queuedStrains.RemoveAt(0); + + // We want the section to end `MaxSectionLength` after the strain we're using as an influence. + // This effectively means the queued strain will exist in its own section if the gap between the queued strain and current object is large enough. + // This is required to make sure there's no harsh difficulty difference between 2 sections if there was a large gap. + currentSectionEnd = startTime + MaxSectionLength; + startNewSectionFrom(currentSectionBegin, current); + + // If the current object's peak was higher, we don't want to override it with a lower strain. + // Only use the queued strain if it contributes more difficulty. + currentSectionPeak = Math.Max(currentSectionPeak, strain); + } + // If the queue is empty then we should start the section from the current object instead. + // The queue can be empty if we're starting off of the back of a new peak, or if we drained through all the queue + // and the current object is still later than the section end. + else + { + // We don't have any prior strains to take as a reference, so end the new section `MaxSectionLength` after it starts. + currentSectionEnd = currentSectionBegin + MaxSectionLength; + startNewSectionFrom(currentSectionBegin, current); + } + } + } + + /// + /// Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty. + /// + private void saveCurrentPeak(double sectionLength) + { + strainPeaks.AddInPlace(new StrainPeak(currentSectionPeak, sectionLength)); + totalLength += sectionLength; + + // Remove from the back of our strain peaks if there's any which are too deep to contribute to difficulty. + // `maxStoredSections` dictates for us how many sections will preserve at least 99.999% of the difficulty value. + while (totalLength > maxStoredSections * MaxSectionLength) + { + totalLength -= strainPeaks[0].SectionLength; + strainPeaks.RemoveAt(0); + } + } + + /// + /// Sets the initial strain level for a new section. + /// + /// The beginning of the new section in milliseconds. + /// The current hit object. + private void startNewSectionFrom(double time, DifficultyHitObject current) + { + // The maximum strain of the new section is not zero by default + // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. + currentSectionPeak = CalculateInitialStrain(time, current); + } + + /// + /// Retrieves the peak strain at a point in time. + /// + /// The time to retrieve the peak strain at. + /// The current hit object. + /// The peak strain. + protected abstract double CalculateInitialStrain(double time, DifficultyHitObject current); + + /// + /// Returns a live enumerable of the peak strains for each section of the beatmap, + /// including the peak of the current section. + /// + public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(new StrainPeak(currentSectionPeak, currentSectionEnd - currentSectionBegin)); + + /// + /// Returns the calculated difficulty value representing all s that have been processed up to this point. + /// + public override double DifficultyValue() + { + double difficulty = 0; + + // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These sections will not contribute to the difficulty. + var peaks = GetCurrentStrainPeaks().Where(p => p.Value > 0); + + List strains = peaks.OrderByDescending(p => (p.Value, p.SectionLength)).ToList(); + + // Time is measured in units of strains + double time = 0; + + // Difficulty is a continuous weighted sum of the sorted strains + for (int i = 0; i < strains.Count; i++) + { + /* Weighting function can be thought of as: + b + ∫ DecayWeight^x dx + a + where a = startTime and b = endTime + + Technically, the function below has been slightly modified from the equation above. + The real function would be + double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime)) + ... + return difficulty / Math.Log(1 / DecayWeight) + E.g. for a DecayWeight of 0.9, we're multiplying by 10 instead of 9.49122... + + This change makes it so that a map composed solely of MaxSectionLength chunks will have the exact same value when summed in this class and StrainSkill. + Doing this ensures the relationship between strain values and difficulty values remains the same between the two classes. + */ + double startTime = time; + double endTime = time + strains[i].SectionLength; + + double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime); + + difficulty += strains[i].Value * weight; + time = endTime; + } + + return difficulty / (1 - DecayWeight); + } + + /// + /// Calculates the number of strains weighted against the top strain. + /// The result is scaled by clock rate as it affects the total number of strains. + /// + public virtual double CountTopWeightedStrains(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical + + if (consistentTopStrain == 0) + return ObjectDifficulties.Count; + + // Use a weighted sum of all strains. Constants are arbitrary and give nice values + return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + } + + /// + /// Used to store the difficulty of a section of a map. + /// + public readonly struct StrainPeak : IComparable + { + public StrainPeak(double value, double sectionLength) + { + Value = value; + SectionLength = Math.Round(sectionLength); + } + + public double Value { get; } + public double SectionLength { get; } + + public int CompareTo(StrainPeak other) => Value.CompareTo(other.Value); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/Utils/Bin.cs b/osu.Game/Rulesets/Difficulty/Utils/Bin.cs new file mode 100644 index 000000000000..7dd9d91a028a --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Utils/Bin.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace osu.Game.Rulesets.Difficulty.Utils +{ + public struct Bin + { + public double Difficulty; + public double Time; + public double NoteCount; + + /// + /// Creates bins using 2D quantile-based binning. + /// First splits notes into time quantiles (equal note counts), then splits each time quantile into difficulty quantiles. + /// + public static List CreateBins(List difficulties, List times, int difficultyDimensionLength, int timeDimensionLength) + { + if (difficulties.Count == 0 || times.Count == 0 || difficultyDimensionLength <= 0 || timeDimensionLength <= 0) + return new List(); + + int n = difficulties.Count; + var bins = new List(); + + // Calculate how many notes per time quantile + int notesPerTimeQuantile = (int)Math.Ceiling((double)n / timeDimensionLength); + + // Split into time quantiles + for (int timeQuantile = 0; timeQuantile < timeDimensionLength; timeQuantile++) + { + int startIdx = timeQuantile * notesPerTimeQuantile; + int endIdx = Math.Min(startIdx + notesPerTimeQuantile, n); + + if (startIdx >= n) break; + + int quantileSize = endIdx - startIdx; + + // Extract difficulties and times for this time quantile + var quantileDifficulties = new List(quantileSize); + var quantileTimes = new List(quantileSize); + + for (int i = startIdx; i < endIdx; i++) + { + quantileDifficulties.Add(difficulties[i]); + quantileTimes.Add(times[i]); + } + + // Sort by difficulty for this quantile + int[] sortedIndices = Enumerable.Range(0, quantileSize) + .OrderBy(i => quantileDifficulties[i]) + .ToArray(); + + // Calculate how many notes per difficulty quantile + int notesPerDiffQuantile = (int)Math.Ceiling((double)quantileSize / difficultyDimensionLength); + + // Split this time quantile into difficulty quantiles + for (int diffQuantile = 0; diffQuantile < difficultyDimensionLength; diffQuantile++) + { + int diffStartIdx = diffQuantile * notesPerDiffQuantile; + int diffEndIdx = Math.Min(diffStartIdx + notesPerDiffQuantile, quantileSize); + + if (diffStartIdx >= quantileSize) + break; + + double diffSum = 0; + double timeSum = 0; + int count = diffEndIdx - diffStartIdx; + + for (int i = diffStartIdx; i < diffEndIdx; i++) + { + int originalIdx = sortedIndices[i]; + diffSum += quantileDifficulties[originalIdx]; + timeSum += quantileTimes[originalIdx]; + } + + bins.Add(new Bin + { + Difficulty = diffSum / count, + Time = timeSum / count, + NoteCount = count + }); + } + } + + // Sort by time + return bins.OrderBy(b => b.Time).ToList(); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs index c813627d5162..cf1fe6957236 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs @@ -190,5 +190,48 @@ public static double ErfInv(double x) /// /// Value to calculate the function for public static double ErfcInv(double x) => ErfInv(1 - x); + + /// + /// Computes the cumulative distribution (CDF) of the distribution at x, i.e. P(X ≤ x). + /// + /// The location at which to compute the cumulative distribution function. + /// The mean (μ) of the normal distribution. + /// The standard deviation (σ) of the normal distribution. Range: σ ≥ 0. + /// the cumulative distribution at location . + public static double NormalCdf(double mean, double stddev, double x) + { + const double sqrt2 = 1.4142135623730950488016887242096980785696718753769d; + + if (stddev < 0.0) + { + throw new ArgumentException("Invalid parametrization for the distribution."); + } + + if (mean == x && stddev == 0) + return 0; + + return 0.5 * Erfc((mean - x) / (stddev * sqrt2)); + } + + /// + /// Computes the probability density of the distribution (PDF) at x, i.e. ∂P(X ≤ x)/∂x. + /// + /// The mean (μ) of the normal distribution. + /// The standard deviation (σ) of the normal distribution. Range: σ ≥ 0. + /// The location at which to compute the density. + /// the density at . + /// MATLAB: normpdf + public static double NormalPdf(double mean, double stddev, double x) + { + const double sqrt2_pi = 2.5066282746310005024157652848110452530069867406099d; + + if (stddev < 0.0) + { + throw new ArgumentException("Invalid parametrization for the distribution."); + } + + double d = (x - mean) / stddev; + return Math.Exp(-0.5 * d * d) / (sqrt2_pi * stddev); + } } } diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_PolynomialSolver.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_PolynomialSolver.cs new file mode 100644 index 000000000000..1ed4a39d605d --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils_PolynomialSolver.cs @@ -0,0 +1,253 @@ +// 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.Utils; + +namespace osu.Game.Rulesets.Difficulty.Utils +{ + public partial class DifficultyCalculationUtils + { + private const double pi_mult_2 = 6.28318530717958647692528676655900576d; + + /// + /// Solve for the exact real roots of any polynomial up to degree 4. + /// + /// The coefficients of the polynomial, in ascending order ([1, 3, 5] -> x^2 + 3x + 5). + /// The real roots of the polynomial, and null if the root does not exist. + public static List SolvePolynomialRoots(List coefficients) + { + List xVals = new List(); + + switch (coefficients.Count) + { + case 5: + xVals = solveP4(coefficients[0], coefficients[1], coefficients[2], coefficients[3], coefficients[4], out int _).ToList(); + break; + + case 4: + xVals = solveP3(coefficients[0], coefficients[1], coefficients[2], coefficients[3], out int _).ToList(); + break; + + case 3: + xVals = solveP2(coefficients[0], coefficients[1], coefficients[2], out int _).ToList(); + break; + + case 2: + xVals = solveP2(0, coefficients[1], coefficients[2], out int _).ToList(); + break; + } + + return xVals; + } + + // https://github.com/sasamil/Quartic/blob/master/quartic.cpp + private static double?[] solveP4(double a, double b, double c, double d, double e, out int nRoots) + { + double?[] xVals = new double?[4]; + + nRoots = 0; + + if (a == 0) + { + double?[] xValsCubic = solveP3(b, c, d, e, out nRoots); + + xVals[0] = xValsCubic[0]; + xVals[1] = xValsCubic[1]; + xVals[2] = xValsCubic[2]; + xVals[3] = null; + + return xVals; + } + + b /= a; + c /= a; + d /= a; + e /= a; + + double a3 = -c; + double b3 = b * d - 4 * e; + double c3 = -b * b * e - d * d + 4 * c * e; + + double?[] x3 = solveP3(1, a3, b3, c3, out int iZeroes); + + double q1, q2, p1, p2, sqD; + + double y = x3[0]!.Value; + + // Get the y value with the highest absolute value. + if (iZeroes != 1) + { + if (Math.Abs(x3[1]!.Value) > Math.Abs(y)) + y = x3[1]!.Value; + if (Math.Abs(x3[2]!.Value) > Math.Abs(y)) + y = x3[2]!.Value; + } + + double upperD = y * y - 4 * e; + + if (Precision.AlmostEquals(upperD, 0)) + { + q1 = q2 = y * 0.5; + + upperD = b * b - 4 * (c - y); + + if (Precision.AlmostEquals(upperD, 0)) + p1 = p2 = b * 0.5; + + else + { + sqD = Math.Sqrt(upperD); + p1 = (b + sqD) * 0.5; + p2 = (b - sqD) * 0.5; + } + } + else + { + sqD = Math.Sqrt(upperD); + q1 = (y + sqD) * 0.5; + q2 = (y - sqD) * 0.5; + + p1 = (b * q1 - d) / (q1 - q2); + p2 = (d - b * q2) / (q1 - q2); + } + + // solving quadratic eq. - x^2 + p1*x + q1 = 0 + upperD = p1 * p1 - 4 * q1; + + if (upperD >= 0) + { + nRoots += 2; + + sqD = Math.Sqrt(upperD); + xVals[0] = (-p1 + sqD) * 0.5; + xVals[1] = (-p1 - sqD) * 0.5; + } + + // solving quadratic eq. - x^2 + p2*x + q2 = 0 + upperD = p2 * p2 - 4 * q2; + + if (upperD >= 0) + { + nRoots += 2; + + sqD = Math.Sqrt(upperD); + xVals[2] = (-p2 + sqD) * 0.5; + xVals[3] = (-p2 - sqD) * 0.5; + } + + // Put the null roots at the end of the array. + var nonNulls = xVals.Where(x => x != null); + var nulls = xVals.Where(x => x == null); + xVals = nonNulls.Concat(nulls).ToArray(); + + return xVals; + } + + private static double?[] solveP3(double a, double b, double c, double d, out int nRoots) + { + double?[] xVals = new double?[3]; + + nRoots = 0; + + if (a == 0) + { + double?[] xValsQuadratic = solveP2(b, c, d, out nRoots); + + xVals[0] = xValsQuadratic[0]; + xVals[1] = xValsQuadratic[1]; + xVals[2] = null; + + return xVals; + } + + b /= a; + c /= a; + d /= a; + + double a2 = b * b; + double q = (a2 - 3 * c) / 9; + double q3 = q * q * q; + double r = (b * (2 * a2 - 9 * c) + 27 * d) / 54; + double r2 = r * r; + + if (r2 < q3) + { + nRoots = 3; + + double t = r / Math.Sqrt(q3); + t = Math.Clamp(t, -1, 1); + t = Math.Acos(t); + b /= 3; + q = -2 * Math.Sqrt(q); + + xVals[0] = q * Math.Cos(t / 3) - b; + xVals[1] = q * Math.Cos((t + pi_mult_2) / 3) - b; + xVals[2] = q * Math.Cos((t - pi_mult_2) / 3) - b; + + return xVals; + } + + double upperA = -Math.Cbrt(Math.Abs(r) + Math.Sqrt(r2 - q3)); + + if (r < 0) + upperA = -upperA; + + double upperB = upperA == 0 ? 0 : q / upperA; + b /= 3; + + xVals[0] = upperA + upperB - b; + + if (Precision.AlmostEquals(0.5 * Math.Sqrt(3) * (upperA - upperB), 0)) + { + nRoots = 2; + xVals[1] = -0.5 * (upperA + upperB) - b; + + return xVals; + } + + nRoots = 1; + + return xVals; + } + + private static double?[] solveP2(double a, double b, double c, out int nRoots) + { + double?[] xVals = new double?[2]; + + nRoots = 0; + + if (a == 0) + { + if (b == 0) + return xVals; + + nRoots = 1; + xVals[0] = -c / b; + } + + double discriminant = b * b - 4 * a * c; + + switch (discriminant) + { + case < 0: + break; + + case 0: + nRoots = 1; + xVals[0] = -b / (2 * a); + break; + + default: + nRoots = 2; + xVals[0] = (-b + Math.Sqrt(discriminant)) / (2 * a); + xVals[1] = (-b - Math.Sqrt(discriminant)) / (2 * a); + break; + } + + return xVals; + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/Utils/PoissonBinomial.cs b/osu.Game/Rulesets/Difficulty/Utils/PoissonBinomial.cs new file mode 100644 index 000000000000..db411397f1d7 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Utils/PoissonBinomial.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Difficulty.Utils +{ + /// + /// Approximation of the Poisson binomial distribution: + /// https://en.wikipedia.org/wiki/Poisson_binomial_distribution + /// + /// + /// + /// For the approximation method, see "Refined Normal Approximation (RNA)" from: + /// Hong, Y. (2013). On computing the distribution function for the Poisson binomial distribution. Computational Statistics and Data Analysis, Vol. 59, pp. 41-51. + /// (https://www.researchgate.net/publication/257017356_On_computing_the_distribution_function_for_the_Poisson_binomial_distribution) + /// + /// + /// This has been verified against a reference implementation provided by the authors in the R package "poibin", + /// which can be viewed here: + /// https://rdrr.io/cran/poibin/man/poibin-package.html + /// + /// + public class PoissonBinomial + { + /// + /// The expected value of the distribution. + /// + private readonly double mu; + + /// + /// The standard deviation of the distribution. + /// + private readonly double sigma; + + /// + /// The gamma factor from equation (11) in the cited paper, pre-divided by 6 to save on re-computation. + /// + private readonly double v; + + /// + /// Creates a Poisson binomial distribution based on N trials with the provided difficulties, skill, and method for getting the miss probabilities. + /// + /// The list of difficulties in the map. + /// The skill level to get the miss probabilities with. + /// Converts difficulties and skill to miss probabilities. + public PoissonBinomial(IList difficulties, double skill, Func hitProbability) + { + double variance = 0; + double gamma = 0; + + foreach (double d in difficulties) + { + double p = 1 - hitProbability(skill, d); + + mu += p; + variance += p * (1 - p); + gamma += p * (1 - p) * (1 - 2 * p); + } + + sigma = Math.Sqrt(variance); + + v = gamma / (6 * Math.Pow(sigma, 3)); + } + + /// + /// Creates a Poisson binomial distribution based on N trials with the provided bins of difficulties, skill, and method for getting the miss probabilities. + /// + /// The bins of difficulties in the map. + /// The skill level to get the miss probabilities with. + /// /// Converts difficulties and skill to miss probabilities. + public PoissonBinomial(List bins, double skill, Func hitProbability) + { + double variance = 0; + double gamma = 0; + + foreach (Bin bin in bins) + { + double p = 1 - hitProbability(skill, bin.Difficulty); + + mu += p * bin.NoteCount; + variance += p * (1 - p) * bin.NoteCount; + gamma += p * (1 - p) * (1 - 2 * p) * bin.NoteCount; + } + + sigma = Math.Sqrt(variance); + + v = gamma / (6 * Math.Pow(sigma, 3)); + } + + /// + /// Computes the value of the cumulative distribution function for this Poisson binomial distribution. + /// + /// + /// The argument of the CDF to sample the distribution for. + /// In the discrete case (when it is a whole number), this corresponds to the number + /// of successful Bernoulli trials to query the CDF for. + /// + /// + /// The value of the CDF at . + /// In the discrete case this corresponds to the probability that at most + /// Bernoulli trials ended in a success. + /// + // ReSharper disable once InconsistentNaming + public double CDF(double count) + { + if (sigma == 0) + return 1; + + double k = (count + 0.5 - mu) / sigma; + + // see equation (14) of the cited paper + double result = DifficultyCalculationUtils.NormalCdf(0, 1, k) + v * (1 - k * k) * DifficultyCalculationUtils.NormalPdf(0, 1, k); + + return Math.Clamp(result, 0, 1); + } + } + + public class IterativePoissonBinomial + { + private double mu, var, gamma; + + public void Reset() + { + mu = 0; + var = 0; + gamma = 0; + } + + public void AddProbability(double p) + { + mu += p; + var += p * (1 - p); + gamma += p * (1 - p) * (1 - 2 * p); + } + + public void RemoveProbability(double p) + { + mu -= p; + var -= p * (1 - p); + gamma -= p * (1 - p) * (1 - 2 * p); + } + + public void AddBinnedProbabilities(double p, double count) + { + mu += p * count; + var += p * (1 - p) * count; + gamma += p * (1 - p) * (1 - 2 * p) * count; + } + + public void RemoveBinnedProbabilities(double p, double count) + { + mu -= p * count; + var -= p * (1 - p) * count; + gamma -= p * (1 - p) * (1 - 2 * p) * count; + } + + public double Cdf(double count) + { + if (var == 0) + return mu <= count ? 1 : 0; + + double sigma = Math.Sqrt(var); + double v = gamma / (6 * Math.Pow(sigma, 3)); + double k = (count + 0.5 - mu) / sigma; + + double result = DifficultyCalculationUtils.NormalCdf(0, 1, k) + v * (1 - k * k) * DifficultyCalculationUtils.NormalPdf(0, 1, k); + + return Math.Clamp(result, 0, 1); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/Utils/PolynomialPenaltyUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/PolynomialPenaltyUtils.cs new file mode 100644 index 000000000000..6c0d19924444 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Utils/PolynomialPenaltyUtils.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; +using System.Collections.Generic; +using System.Linq; + +namespace osu.Game.Rulesets.Difficulty.Utils +{ + /// + /// Represents a polynomial fitted to a given set of points. + /// + public static class PolynomialPenaltyUtils + { + /// + /// The proportions of skill that this polynomial will fit a curve to the miss counts of. + /// If you want to change these, you need to recompute the as it is precomputed to fit these specific values. + /// + public static readonly double[] SKILL_PROPORTIONS = [1, 0.95, 0.9, 0.8, 0.6, 0.3, 0]; + + // Pre-calculated matrix used for curve fitting. + // It's derived using least-squares regression to find the best-fit polynomial through our data points. + private static readonly double[][] matrix = + { + new[] { 0.0, -25.8899, -32.6909, -11.9147, 48.8588, -26.8943, 0.0 }, + new[] { 0.0, 51.7787, 66.595, 28.8517, -90.3185, 40.9864, 0.0 }, + new[] { 0.0, -31.5028, -41.7398, -22.7118, 46.438, -15.5156, 0.0 } + }; + + /// + /// Creates a polynomial curve that maps miss counts to miss penalties. + /// Used to smoothly interpolate between miss counts, with 0 misses fixed to 0% penalty, and all misses fixed to 100% penalty. + /// + /// + /// A dictionary of miss counts, with keys representing skill proportions and values representing the miss count a player would achieve at that skill proportion. + /// See comment on if you want to use custom skill proportions. + /// + public static double[] GetPenaltyCoefficients(Dictionary missCounts) + { + double endPoint = missCounts.Values.Max(); + + double[] sortedSkillProportions = missCounts.Keys.OrderByDescending(k => k).ToArray(); + + double[] coefficients = new double[4]; + + coefficients[3] = endPoint; + + // Now we dot product the adjusted miss counts with the matrix. + for (int row = 0; row < matrix.Length; row++) + { + for (int column = 0; column < matrix[row].Length; column++) + { + double skillProportion = sortedSkillProportions[column]; + double missCountAtSkill = missCounts[skillProportion]; + + coefficients[row] += matrix[row][column] * (missCountAtSkill - endPoint * (1 - skillProportion)); + } + + coefficients[3] -= coefficients[row]; + } + + return coefficients; + } + + /// + /// Calculates what percentage penalty the player should receive. + /// + /// The coefficients to achieve the penalty at + /// The number of misses the player got + /// A value between 0 and 1 representing the penalty percentage (0 = no penalty, 1 = full penalty) + public static double GetPenaltyAt(double[] coefficients, double missCount) + { + // Our first coefficients are the ones derived from the skill proportion miss counts, + // and subtracting missCount for the last one sets our root to the corresponding penalty. + List listCoefficients = [..coefficients, -missCount]; + + List xVals = DifficultyCalculationUtils.SolvePolynomialRoots(listCoefficients); + + const double max_error = 1e-7; + + // This will never happen (it is physically impossible for there to not be a root), + // but in the interest of sanity we fall back to a 100% penalty if no roots were found. + double largestValue = xVals.Where(x => x >= 0 - max_error && x <= 1 + max_error).OrderDescending().FirstOrDefault() ?? 1; + + return Math.Clamp(largestValue, 0, 1); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/Utils/RootFinding.cs b/osu.Game/Rulesets/Difficulty/Utils/RootFinding.cs new file mode 100644 index 000000000000..a1c9b5d49b6c --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Utils/RootFinding.cs @@ -0,0 +1,119 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Rulesets.Difficulty.Utils +{ + public static class RootFinding + { + /// + /// Finds the root of a using the Chandrupatla method, expanding the bounds if the root is not located within. + /// Expansion only occurs for the upward bound, as this function is optimized for functions of range [0, x), + /// which is useful for finding skill level (skill can never be below 0). + /// + /// The function of which to find the root. + /// The lower bound of the function inputs. + /// The upper bound of the function inputs. + /// The maximum number of iterations before the function throws an error. + /// The desired precision in which the root is returned. + /// The multiplier on the upper bound when no root is found within the provided bounds. + /// The maximum number of times the bounds of the function should increase. + public static double FindRootExpand(Func function, double guessLowerBound, double guessUpperBound, int maxIterations = 25, double accuracy = 1e-6D, double expansionFactor = 2, double maxExpansions = 32) + { + double a = guessLowerBound; + double b = guessUpperBound; + double fa = function(a); + double fb = function(b); + + int expansions = 0; + + while (fa * fb > 0) + { + a = b; + b *= expansionFactor; + fa = function(a); + fb = function(b); + + expansions++; + + if (expansions > maxExpansions) + { + throw new MaximumIterationsException("No root was found within the provided function."); + } + } + + double t = 0.5; + + for (int i = 0; i < maxIterations; i++) + { + double xt = a + t * (b - a); + double ft = function(xt); + + double c; + double fc; + + if (Math.Sign(ft) == Math.Sign(fa)) + { + c = a; + fc = fa; + } + else + { + c = b; + b = a; + fc = fb; + fb = fa; + } + + a = xt; + fa = ft; + + double xm, fm; + + if (Math.Abs(fa) < Math.Abs(fb)) + { + xm = a; + fm = fa; + } + else + { + xm = b; + fm = fb; + } + + if (fm == 0) + return xm; + + double tol = 2 * accuracy * Math.Abs(xm) + 2 * accuracy; + double tlim = tol / Math.Abs(b - c); + + if (tlim > 0.5) + { + return xm; + } + + double chi = (a - b) / (c - b); + double phi = (fa - fb) / (fc - fb); + bool iqi = phi * phi < chi && (1 - phi) * (1 - phi) < chi; + + if (iqi) + t = fa / (fb - fa) * fc / (fb - fc) + (c - a) / (b - a) * fa / (fc - fa) * fb / (fc - fb); + else + t = 0.5; + + t = Math.Min(1 - tlim, Math.Max(tlim, t)); + } + + return 0; + } + + private class MaximumIterationsException : Exception + { + public MaximumIterationsException(string message) + : base(message) + { + } + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/IssueTemplate.cs b/osu.Game/Rulesets/Edit/Checks/Components/IssueTemplate.cs index 97df79ecd8a6..955bc0265bcc 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/IssueTemplate.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/IssueTemplate.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using Humanizer; using osu.Framework.Graphics; using osuTK.Graphics; @@ -42,7 +41,7 @@ public IssueTemplate(ICheck check, IssueType type, string unformattedMessage) /// Returns the formatted message given the arguments used to format it. /// /// The arguments used to format the message. - public string GetMessage(params object[] args) => UnformattedMessage.FormatWith(args); + public string GetMessage(params object[] args) => string.Format(UnformattedMessage, args); /// /// Returns the colour corresponding to the type of this issue. diff --git a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs index 2d6e09b3fdcd..0b8df0ad417a 100644 --- a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs @@ -20,12 +20,12 @@ using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.OSD; -using osu.Game.Overlays.Settings.Sections; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.TernaryButtons; +using osuTK; namespace osu.Game.Rulesets.Edit { @@ -42,7 +42,7 @@ public abstract partial class ComposerDistanceSnapProvider : Component, IDistanc Bindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; - private ExpandableSlider> distanceSpacingSlider = null!; + private ExpandableSlider distanceSpacingSlider = null!; private ExpandableButton currentDistanceSpacingButton = null!; [Resolved] @@ -75,14 +75,16 @@ public void AttachToToolbox(ExpandingToolboxContainer toolboxContainer) toolboxContainer.Add(toolboxGroup = new EditorToolboxGroup("snapping") { Name = "snapping", + Spacing = new Vector2(5), Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, Children = new Drawable[] { - distanceSpacingSlider = new ExpandableSlider> + distanceSpacingSlider = new ExpandableSlider { KeyboardStep = adjust_step, // Manual binding in LoadComplete to handle one-way event flow. Current = DistanceSpacingMultiplier.GetUnboundCopy(), + ExpandedLabelText = "Distance spacing", }, currentDistanceSpacingButton = new ExpandableButton { @@ -104,7 +106,7 @@ public void AttachToToolbox(ExpandingToolboxContainer toolboxContainer) DistanceSpacingMultiplier.BindValueChanged(multiplier => { distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})"; - distanceSpacingSlider.ExpandedLabelText = $"Distance Spacing ({multiplier.NewValue:0.##x})"; + distanceSpacingSlider.Current.Value = multiplier.NewValue; if (multiplier.NewValue != multiplier.OldValue) onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier)); @@ -306,7 +308,7 @@ private partial class DistanceSpacingToast : Toast private readonly ValueChangedEvent change; public DistanceSpacingToast(LocalisableString value, ValueChangedEvent change) - : base(getAction(change).GetLocalisableDescription(), value, string.Empty) + : base(getAction(change).GetLocalisableDescription(), value) { this.change = change; } @@ -314,7 +316,7 @@ public DistanceSpacingToast(LocalisableString value, ValueChangedEvent c [BackgroundDependencyLoader] private void load(RealmKeyBindingStore keyBindingStore) { - ShortcutText.Text = keyBindingStore.GetBindingsStringFor(getAction(change)).ToUpper(); + ExtraText = keyBindingStore.GetBindingsStringFor(getAction(change)); } private static GlobalAction getAction(ValueChangedEvent change) => change.NewValue - change.OldValue > 0 diff --git a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs index 174b278d8991..19ab9657dc30 100644 --- a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs +++ b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs @@ -49,17 +49,17 @@ protected override void LoadComplete() { base.LoadComplete(); - beatmap.HitObjectAdded += addHitObject; - beatmap.HitObjectRemoved += removeHitObject; + beatmap.HitObjectAdded += hitObjectAdded; + beatmap.HitObjectRemoved += hitObjectRemoved; if (changeHandler != null) { // for now only regenerate replay on a finalised state change, not HitObjectUpdated. - changeHandler.OnStateChange += () => Scheduler.AddOnce(regenerateAutoplay); + changeHandler.OnStateChange += stateChanged; } else { - beatmap.HitObjectUpdated += _ => Scheduler.AddOnce(regenerateAutoplay); + beatmap.HitObjectUpdated += hitObjectUpdated; } Scheduler.AddOnce(regenerateAutoplay); @@ -71,18 +71,22 @@ private void regenerateAutoplay() drawableRuleset.SetReplayScore(autoplayMod.CreateScoreFromReplayData(drawableRuleset.Beatmap, drawableRuleset.Mods)); } - private void addHitObject(HitObject hitObject) + private void hitObjectAdded(HitObject hitObject) { drawableRuleset.AddHitObject((TObject)hitObject); drawableRuleset.Playfield.PostProcess(); } - private void removeHitObject(HitObject hitObject) + private void hitObjectRemoved(HitObject hitObject) { drawableRuleset.RemoveHitObject((TObject)hitObject); drawableRuleset.Playfield.PostProcess(); } + private void hitObjectUpdated(HitObject _) => Scheduler.AddOnce(regenerateAutoplay); + + private void stateChanged() => Scheduler.AddOnce(regenerateAutoplay); + public override bool PropagatePositionalInputSubTree => false; public override bool PropagateNonPositionalInputSubTree => false; @@ -95,9 +99,13 @@ protected override void Dispose(bool isDisposing) if (beatmap.IsNotNull()) { - beatmap.HitObjectAdded -= addHitObject; - beatmap.HitObjectRemoved -= removeHitObject; + beatmap.HitObjectAdded -= hitObjectAdded; + beatmap.HitObjectRemoved -= hitObjectRemoved; + beatmap.HitObjectUpdated -= hitObjectUpdated; } + + if (changeHandler != null) + changeHandler.OnStateChange -= stateChanged; } } } diff --git a/osu.Game/Rulesets/Edit/ExpandableButton.cs b/osu.Game/Rulesets/Edit/ExpandableButton.cs index 9139802d68c1..d1f855a8ad7c 100644 --- a/osu.Game/Rulesets/Edit/ExpandableButton.cs +++ b/osu.Game/Rulesets/Edit/ExpandableButton.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Edit { - public partial class ExpandableButton : RoundedButton, IExpandable + public sealed partial class ExpandableButton : RoundedButton, IExpandable { private float actualHeight; diff --git a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs index 6720540ec22d..a24249d42c7f 100644 --- a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs @@ -5,6 +5,7 @@ using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -47,6 +48,8 @@ public abstract partial class HitObjectPlacementBlueprint : PlacementBlueprint private HitObject? getPreviousHitObject() => beatmap.HitObjects.TakeWhile(h => h.StartTime <= startTimeBindable.Value).LastOrDefault(); + protected override bool IsValidForPlacement => HitObject.StartTime >= beatmap.ControlPointInfo.TimingPoints.FirstOrDefault()?.Time; + [Resolved] private IPlacementHandler placementHandler { get; set; } = null!; @@ -87,6 +90,13 @@ public override void EndPlacement(bool commit) placementHandler.HidePlacement(); } + protected override void Update() + { + base.Update(); + + Colour = IsValidForPlacement ? Colour4.White : Colour4.Red; + } + /// /// Updates the time and position of this . /// @@ -103,25 +113,28 @@ public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, do var lastHitObject = getPreviousHitObject(); var lastHitNormal = lastHitObject?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); - if (AutomaticAdditionBankAssignment) - { - // Inherit the addition bank from the previous hit object - // If there is no previous addition, inherit from the normal sample - var lastAddition = lastHitObject?.Samples?.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL) ?? lastHitNormal; - - if (lastAddition != null) - HitObject.Samples = HitObject.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: lastAddition.Bank) : s).ToList(); - } + if (lastHitNormal != null && AutomaticBankAssignment) + // Inherit the bank from the previous hit object + HitObject.Samples = HitObject.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: lastHitNormal.Bank, newEditorAutoBank: true) : s).ToList(); + else + HitObject.Samples = HitObject.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newEditorAutoBank: false) : s).ToList(); if (lastHitNormal != null) { - if (AutomaticBankAssignment) - // Inherit the bank from the previous hit object - HitObject.Samples = HitObject.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: lastHitNormal.Bank) : s).ToList(); + // Inherit the volume and sample set info from the previous hit object + HitObject.Samples = HitObject.Samples.Select(s => s.With( + newVolume: lastHitNormal.Volume, + newSuffix: lastHitNormal.Suffix, + newUseBeatmapSamples: lastHitNormal.UseBeatmapSamples)).ToList(); + } - // Inherit the volume from the previous hit object - HitObject.Samples = HitObject.Samples.Select(s => s.With(newVolume: lastHitNormal.Volume)).ToList(); + if (AutomaticAdditionBankAssignment) + { + string bank = HitObject.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; + HitObject.Samples = HitObject.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: bank, newEditorAutoBank: true) : s).ToList(); } + else + HitObject.Samples = HitObject.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newEditorAutoBank: false) : s).ToList(); if (HitObject is IHasRepeats hasRepeats) { diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 477372b97dcc..79db2817eca0 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -59,21 +59,24 @@ public abstract class Mod : IMod, IEquatable, IDeepCloneable if (bindable.IsDefault) continue; - string valueText; - - switch (bindable) - { - case Bindable b: - valueText = b.Value ? "On" : "Off"; - break; + yield return (attr.Label, GetSettingTooltipText(bindable)); + } + } + } - default: - valueText = bindable.ToString() ?? string.Empty; - break; - } + /// + /// Gets the tooltip text for a specific mod setting. + /// Can be overridden to provide custom formatting for specific settings. + /// + protected virtual LocalisableString GetSettingTooltipText(IBindable bindable) + { + switch (bindable) + { + case Bindable b: + return b.Value ? "On" : "Off"; - yield return (attr.Label, valueText); - } + default: + return bindable.ToString() ?? string.Empty; } } diff --git a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs index f26a1bd477de..404caa6b068a 100644 --- a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs +++ b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs @@ -53,7 +53,7 @@ public class ModAccuracyChallenge : ModFailCondition, IApplicableToScoreProcesso } } - [SettingSource("Minimum accuracy", "Trigger a failure if your accuracy goes below this value.", SettingControlType = typeof(SettingsPercentageSlider))] + [SettingSource("Minimum accuracy", "Trigger a failure if your accuracy goes below this value.", SettingControlType = typeof(MinimumAccuracySlider))] public BindableNumber MinimumAccuracy { get; } = new BindableDouble { MinValue = 0.60, @@ -103,4 +103,12 @@ public enum AccuracyMode Standard, } } + + public partial class MinimumAccuracySlider : SettingsPercentageSlider + { + public MinimumAccuracySlider() + { + KeyboardStep = 0.01f; + } + } } diff --git a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs index 1a2cb08a53f0..09086f4d862d 100644 --- a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs +++ b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs @@ -3,17 +3,18 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Humanizer; using osu.Framework.Bindables; using osu.Framework.Localisation; -using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.Mods { - public abstract class ModEasyWithExtraLives : ModEasy, IApplicableFailOverride, IApplicableToHealthProcessor + public abstract class ModEasyWithExtraLives : ModEasy, IApplicableFailOverride, IApplicableToPlayer, IApplicableToHealthProcessor { [SettingSource("Extra Lives", "Number of extra lives")] public Bindable Retries { get; } = new BindableInt(2) @@ -33,18 +34,26 @@ public abstract class ModEasyWithExtraLives : ModEasy, IApplicableFailOverride, public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModAccuracyChallenge)).ToArray(); - private int retries; + private int? retries; private readonly BindableNumber health = new BindableDouble(); - public override void ApplyToDifficulty(BeatmapDifficulty difficulty) + public void ApplyToPlayer(Player player) { - base.ApplyToDifficulty(difficulty); + // this throw works for two reasons: + // - every time `Player` loads, it deep-clones mods into itself, and the deep clone copies *only* `[SettingsSource]` properties + // - `Player` is the only consumer of `IApplicableToPlayer` and it calls `ApplyToPlayer()` exactly once per mod instance + // if either of the above assumptions no longer holds true for any reason, this will need to be reconsidered + if (retries != null) + throw new InvalidOperationException(@"Cannot apply this mod instance to a player twice."); + retries = Retries.Value; } public bool PerformFail() { + Debug.Assert(retries != null); + if (retries == 0) return true; health.Value = health.MaxValue; diff --git a/osu.Game/Rulesets/Mods/ModExtensions.cs b/osu.Game/Rulesets/Mods/ModExtensions.cs index bd2d42f3ebf4..b9f723e88e23 100644 --- a/osu.Game/Rulesets/Mods/ModExtensions.cs +++ b/osu.Game/Rulesets/Mods/ModExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; @@ -20,6 +21,7 @@ public static Score CreateScoreFromReplayData(this ICreateReplayData mod, IBeatm Replay = replayData.Replay, ScoreInfo = { + Date = DateTimeOffset.Now, User = new APIUser { Id = replayData.User.OnlineID, diff --git a/osu.Game/Rulesets/Mods/ModMuted.cs b/osu.Game/Rulesets/Mods/ModMuted.cs index 933e7f409300..6d24854ac5c4 100644 --- a/osu.Game/Rulesets/Mods/ModMuted.cs +++ b/osu.Game/Rulesets/Mods/ModMuted.cs @@ -96,10 +96,28 @@ public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + + protected override LocalisableString GetSettingTooltipText(IBindable bindable) + { + if (ReferenceEquals(bindable, MuteComboCount)) + return MuteComboSlider.FormatMuteComboValue(MuteComboCount.Value); + + return base.GetSettingTooltipText(bindable); + } } public partial class MuteComboSlider : RoundedSliderBar { - public override LocalisableString TooltipText => Current.Value == 0 ? "always muted" : base.TooltipText; + public MuteComboSlider() + { + KeyboardStep = 1; + } + + public override LocalisableString TooltipText => FormatMuteComboValue(Current.Value); + + public static LocalisableString FormatMuteComboValue(int value) + { + return value == 0 ? "always muted" : value.ToString(); + } } } diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index bb18940f8cd9..a6196dcad4d3 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -8,9 +8,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Beatmaps.Timing; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -70,7 +70,11 @@ public abstract partial class ModNightcore : ModNightcore, IApplicableT { public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - drawableRuleset.Overlays.Add(new NightcoreBeatContainer()); + // from stable: + // > (in a perfect world) tick rates other than 2 imply there isn't a regular offbeat, so offbeat hats would stand out. + // > only enable them if tick rate is a multiple of 2. + bool playHats = Precision.AlmostEquals(drawableRuleset.Beatmap.Difficulty.SliderTickRate % 2, 0); + drawableRuleset.Overlays.Add(new NightcoreBeatContainer(playHats)); } public partial class NightcoreBeatContainer : BeatSyncedContainer @@ -81,9 +85,13 @@ public partial class NightcoreBeatContainer : BeatSyncedContainer private PausableSkinnableSound? finishSample; private int? firstBeat; + private int lastBeat = -1; - public NightcoreBeatContainer() + private readonly bool playHats; + + public NightcoreBeatContainer(bool playHats = true) { + this.playHats = playHats; Divisor = 2; } @@ -116,21 +124,35 @@ protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, if (!firstBeat.HasValue || beatIndex < firstBeat) // decide on a good starting beat index if once has not yet been decided. - firstBeat = beatIndex < 0 ? 0 : (beatIndex / segmentLength + 1) * segmentLength; + firstBeat = beatIndex < 0 ? 0 : (beatIndex / segmentLength) * segmentLength; if (beatIndex >= firstBeat) - playBeatFor(beatIndex % segmentLength, timingPoint.TimeSignature); + playBeatFor(beatIndex, segmentLength, timingPoint); } - private void playBeatFor(int beatIndex, TimeSignature signature) + private void playBeatFor(int beatIndex, int segmentLength, TimingControlPoint timingPoint) { - if (beatIndex == 0) - finishSample?.Play(); + // https://github.com/peppy/osu-stable-reference/blob/6ab0cf1f9f7b3449f5c0d8defcd458aae72cdb88/osu!/Audio/NightcoreBeat.cs#L41 + if (lastBeat == beatIndex) + return; + + lastBeat = beatIndex; + + int beatInSegment = beatIndex % segmentLength; + + if (beatInSegment == 0) + { + // https://github.com/peppy/osu-stable-reference/blob/6ab0cf1f9f7b3449f5c0d8defcd458aae72cdb88/osu!/Audio/NightcoreBeat.cs#L53 + bool playFinish = beatIndex > 0 || !timingPoint.OmitFirstBarLine; + + if (playFinish) + finishSample?.Play(); + } - switch (signature.Numerator) + switch (timingPoint.TimeSignature.Numerator) { case 3: - switch (beatIndex % 6) + switch (beatInSegment % 6) { case 0: kickSample?.Play(); @@ -141,14 +163,15 @@ private void playBeatFor(int beatIndex, TimeSignature signature) break; default: - hatSample?.Play(); + if (playHats) + hatSample?.Play(); break; } break; case 4: - switch (beatIndex % 4) + switch (beatInSegment % 4) { case 0: kickSample?.Play(); @@ -159,7 +182,8 @@ private void playBeatFor(int beatIndex, TimeSignature signature) break; default: - hatSample?.Play(); + if (playHats) + hatSample?.Play(); break; } diff --git a/osu.Game/Rulesets/Mods/ModNoScope.cs b/osu.Game/Rulesets/Mods/ModNoScope.cs index d0c9da669b04..42dea82385a1 100644 --- a/osu.Game/Rulesets/Mods/ModNoScope.cs +++ b/osu.Game/Rulesets/Mods/ModNoScope.cs @@ -62,10 +62,28 @@ public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) ComboBasedAlpha = Math.Max(MIN_ALPHA, 1 - (float)combo.NewValue / HiddenComboCount.Value); }, true); } + + protected override LocalisableString GetSettingTooltipText(IBindable bindable) + { + if (ReferenceEquals(bindable, HiddenComboCount)) + return HiddenComboSlider.FormatHiddenComboValue(HiddenComboCount.Value); + + return base.GetSettingTooltipText(bindable); + } } public partial class HiddenComboSlider : RoundedSliderBar { - public override LocalisableString TooltipText => Current.Value == 0 ? "always hidden" : base.TooltipText; + public HiddenComboSlider() + { + KeyboardStep = 1; + } + + public override LocalisableString TooltipText => FormatHiddenComboValue(Current.Value); + + public static LocalisableString FormatHiddenComboValue(int value) + { + return value == 0 ? "always hidden" : value.ToString(); + } } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 243f79d9060d..0a6ef82b77f7 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -550,7 +550,6 @@ private List convertSoundType(LegacyHitSoundType type, SampleBank } else { - // Todo: This should set the normal SampleInfo if the specified sample file isn't found, but that's a pretty edge-case scenario soundTypes.Add(new FileHitSampleInfo(bankInfo.Filename, bankInfo.Volume)); } @@ -680,14 +679,13 @@ public override bool Equals(object? obj) public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), CustomSampleBank, IsLayered); } - private class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable + public class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable { public readonly string Filename; public FileHitSampleInfo(string filename, int volume) // Force CSS=1 to make sure that the LegacyBeatmapSkin does not fall back to the user skin. - // Note that this does not change the lookup names, as they are overridden locally. - : base(string.Empty, customSampleBank: 1, volume: volume) + : base(HIT_NORMAL, SampleControlPoint.DEFAULT_BANK, customSampleBank: 1, volume: volume) { Filename = filename; } @@ -696,7 +694,7 @@ public FileHitSampleInfo(string filename, int volume) { Filename, Path.ChangeExtension(Filename, null) - }; + }.Concat(base.LookupNames); public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newEditorAutoBank = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs index 4ce8166421e7..b28225d81bae 100644 --- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects.Types; @@ -56,12 +55,12 @@ public static void Reverse(this SliderPath sliderPath, out Vector2 positionalOff if (controlPoints.Count >= 3 && controlPoints[^3].Type == PathType.PERFECT_CURVE && controlPoints[^2].Type == null && segmentEnds.Any()) { double lastSegmentStart = segmentEnds.Length > 1 ? segmentEnds[^2] : 0; - double lastSegmentEnd = segmentEnds[^1]; - var circleArcPath = new List(); - sliderPath.GetPathToProgress(circleArcPath, lastSegmentStart / lastSegmentEnd, 1); - - controlPoints[^2].Position = circleArcPath[circleArcPath.Count / 2]; + // we want to shorten the last perfect segment, preserving its shape, so that its end is consistent with the slider path's end. + // therefore we also reposition the middle point of the segment to be ideally halfway through its arc. + // the end of the segment is assumed to be at path position 1 at all times, + // but the start of the segment cannot be assumed to be at 0 because multi-segment sliders exist. + controlPoints[^2].Position = sliderPath.PositionAt((lastSegmentStart + 1) / 2); } sliderPath.reverseControlPoints(out positionalOffset); diff --git a/osu.Game/Rulesets/RealmRulesetStore.cs b/osu.Game/Rulesets/RealmRulesetStore.cs index 2455a9a73fde..52ea5f7f4b74 100644 --- a/osu.Game/Rulesets/RealmRulesetStore.cs +++ b/osu.Game/Rulesets/RealmRulesetStore.cs @@ -93,6 +93,12 @@ private void prepareDetachedRulesets() $"Ruleset API version is too old (was {instance.RulesetAPIVersionSupported}, expected {Ruleset.CURRENT_RULESET_API_VERSION})"); } + if (r.OnlineID != instanceInfo.OnlineID) + throw new InvalidOperationException($@"Online ID mismatch for ruleset {r.ShortName}: database has {r.OnlineID}, constructed instance has {instanceInfo.OnlineID}"); + + if (r.OnlineID > 0 && rulesets.Any(otherRuleset => otherRuleset.ShortName != r.ShortName && otherRuleset.OnlineID == r.OnlineID)) + throw new InvalidOperationException($@"Ruleset {r.ShortName} shares online ID {r.OnlineID} with another ruleset"); + // If a ruleset isn't up-to-date with the API, it could cause a crash at an arbitrary point of execution. // To eagerly handle cases of missing implementations, enumerate all types here and mark as non-available on throw. resolvedType.Assembly.GetTypes(); @@ -109,7 +115,7 @@ private void prepareDetachedRulesets() catch (Exception ex) { r.Available = false; - LogFailedLoad(r.Name, ex); + LogRulesetFailure(r, ex); } } diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 0dbe6e884560..905c4011dc97 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -13,6 +13,7 @@ using osu.Framework.Input.Bindings; using osu.Framework.IO.Stores; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; @@ -334,13 +335,17 @@ protected Ruleset() public virtual StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty(); /// - /// Get all valid s for this ruleset. - /// Generally used for results display purposes, where it can't be determined if zero-count means the user has not achieved any or the type is not used by this ruleset. + /// Get all s for this ruleset which are important enough to displayed to the end user. + /// Used for results display purposes, where it can't be determined if zero-count means the user has not achieved any or the type is not used by this ruleset. /// + /// + /// is implicitly included. Special types like are not returned by this method. + /// Values are returned as ordered by . + /// /// - /// All valid s along with a display-friendly name. + /// All relevant s along with a display-friendly name. /// - public IEnumerable<(HitResult result, LocalisableString displayName)> GetHitResults() + public IEnumerable<(HitResult result, LocalisableString displayName)> GetHitResultsForDisplay() { var validResults = GetValidHitResults(); @@ -353,6 +358,7 @@ protected Ruleset() case HitResult.None: case HitResult.IgnoreHit: case HitResult.IgnoreMiss: + case HitResult.ComboBreak: // display is handled as a completion count with corresponding "hit" type. case HitResult.LargeTickMiss: case HitResult.SmallTickMiss: @@ -366,12 +372,10 @@ protected Ruleset() /// /// Get all valid s for this ruleset. - /// Generally used for results display purposes, where it can't be determined if zero-count means the user has not achieved any or the type is not used by this ruleset. + /// Used for strict validation purposes. The ruleset should return ALL applicable types here + /// (except and obsolete types). /// - /// - /// is implicitly included. Special types like are ignored even when specified. - /// - protected virtual IEnumerable GetValidHitResults() => EnumExtensions.GetValuesInOrder(); + public virtual IEnumerable GetValidHitResults() => EnumExtensions.GetValuesInOrder(); /// /// Get a display friendly name for the specified result type. diff --git a/osu.Game/Rulesets/RulesetSelector.cs b/osu.Game/Rulesets/RulesetSelector.cs index ba10033a9876..220e9f544dc4 100644 --- a/osu.Game/Rulesets/RulesetSelector.cs +++ b/osu.Game/Rulesets/RulesetSelector.cs @@ -3,9 +3,9 @@ #nullable disable -using osu.Framework.Graphics.UserInterface; +using System; using osu.Framework.Allocation; -using osu.Framework.Logging; +using osu.Framework.Graphics.UserInterface; using osu.Game.Extensions; namespace osu.Game.Rulesets @@ -31,9 +31,9 @@ private void load() { AddItem(ruleset); } - catch + catch (Exception e) { - Logger.Log($"Could not create ruleset icon for {ruleset.Name}. Please check for an update from the developer.", level: LogLevel.Error); + RulesetStore.LogRulesetFailure(ruleset, e); } } } diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index f33d42a53eec..9062cf32fccb 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -147,7 +147,7 @@ private void loadFromDisk() } catch (Exception e) { - LogFailedLoad(filename, e); + logRulesetFailure(filename, e); } return null; @@ -169,7 +169,7 @@ private void addRuleset(Assembly assembly) } catch (Exception e) { - LogFailedLoad(assembly.GetName().Name!.Split('.').Last(), e); + logRulesetFailure(assembly.GetName().Name!.Split('.').Last(), e); } } @@ -184,10 +184,12 @@ protected void Dispose(bool disposing) AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; } - protected void LogFailedLoad(string name, Exception exception) + public static void LogRulesetFailure(RulesetInfo ruleset, Exception e) => logRulesetFailure(ruleset.Name, e); + + private static void logRulesetFailure(string name, Exception exception) { - Logger.Log($"Could not load ruleset \"{name}\". Please check for an update from the developer.", level: LogLevel.Error); - Logger.Log($"Ruleset load failed: {exception}"); + Logger.Log($"An issue with ruleset \"{name}\" occurred. Please check for an update from the developer.", level: LogLevel.Error); + Logger.Log(exception.ToString()); } #region Implementation of IRulesetStore diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 393df65cc8c4..a03fee5cfd96 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -188,7 +188,7 @@ private void readCompressedData(byte[] data, Action readFunc) long compressedSize = replayInStream.Length - replayInStream.Position; - using (var lzma = new LzmaStream(properties, replayInStream, compressedSize, outSize)) + using (var lzma = LzmaStream.Create(properties, replayInStream, compressedSize, outSize)) using (var reader = new StreamReader(lzma)) readFunc(reader); } @@ -211,7 +211,7 @@ public static void PopulateMaximumStatistics(ScoreInfo score, WorkingBeatmap wor var scoreProcessor = rulesetInstance.CreateScoreProcessor(); // Populate the maximum statistics. - HitResult maxBasicResult = rulesetInstance.GetHitResults() + HitResult maxBasicResult = rulesetInstance.GetHitResultsForDisplay() .Select(h => h.result) .Where(h => h.IsBasic()).MaxBy(scoreProcessor.GetBaseScoreForResult); diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index b575c0233754..ed1df16573f2 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -120,7 +120,7 @@ private byte[] compress(string data) using (var outStream = new MemoryStream()) { - using (var lzma = new LzmaStream(new LzmaEncoderProperties(false, 1 << 21, 255), false, outStream)) + using (var lzma = LzmaStream.Create(new LzmaEncoderProperties(false, 1 << 21, 255), false, outStream)) { outStream.Write(lzma.Properties); diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 9e10b93168f6..5c524e9d36f7 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -361,7 +361,7 @@ private void updateModsJson() public IEnumerable GetStatisticsForDisplay() { - foreach (var r in Ruleset.CreateInstance().GetHitResults()) + foreach (var r in Ruleset.CreateInstance().GetHitResultsForDisplay()) { int value = Statistics.GetValueOrDefault(r.result); diff --git a/osu.Game/Scoring/ScoreInfoExtensions.cs b/osu.Game/Scoring/ScoreInfoExtensions.cs index 2eec0399d617..fc367554e7de 100644 --- a/osu.Game/Scoring/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/ScoreInfoExtensions.cs @@ -7,8 +7,8 @@ using osu.Game.Beatmaps; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Leaderboards; using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Select.Leaderboards; using Realms; namespace osu.Game.Scoring diff --git a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs index 24b582b71b5d..07cb9f5be576 100644 --- a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs +++ b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs @@ -13,12 +13,14 @@ using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; +using osu.Game.Screens.Edit; using osu.Game.Storyboards.Drawables; namespace osu.Game.Screens.Backgrounds { public partial class EditorBackgroundScreen : BackgroundScreen { + private readonly EditorBeatmap editorBeatmap; private readonly Container dimContainer; private CancellationTokenSource? cancellationTokenSource; @@ -36,8 +38,9 @@ public partial class EditorBackgroundScreen : BackgroundScreen [Resolved] private IBindable beatmap { get; set; } = null!; - public EditorBackgroundScreen() + public EditorBackgroundScreen(EditorBeatmap editorBeatmap) { + this.editorBeatmap = editorBeatmap; InternalChild = dimContainer = new Container { RelativeSizeAxes = Axes.Both, @@ -58,10 +61,11 @@ private void load(OsuConfigManager config) private IEnumerable createContent() => [ new BeatmapBackground(beatmap.Value) { RelativeSizeAxes = Axes.Both, }, - // this kooky container nesting is here because the storyboard needs a custom clock + // one reason for this kooky container nesting being here is that the storyboard needs a custom clock // but also needs it on an isolated-enough level that doesn't break screen stack expiry logic (which happens if the clock was put on `this`), // or doesn't make it literally impossible to fade the storyboard in/out in real time (which happens if the fade transforms were to be applied directly to the storyboard). - new Container + // another is that we need `EditorSkinProvidingContainer` so that storyboard sample lookups succeed. + new EditorSkinProvidingContainer(editorBeatmap) { RelativeSizeAxes = Axes.Both, Child = new DrawableStoryboard(beatmap.Value.Storyboard) diff --git a/osu.Game/Screens/Edit/BackgroundDimMenuItem.cs b/osu.Game/Screens/Edit/BackgroundDimMenuItem.cs index 2a1159eb2782..26f2f145e9ba 100644 --- a/osu.Game/Screens/Edit/BackgroundDimMenuItem.cs +++ b/osu.Game/Screens/Edit/BackgroundDimMenuItem.cs @@ -24,6 +24,7 @@ public BackgroundDimMenuItem(Bindable backgroundDim) createMenuItem(0.25f), createMenuItem(0.5f), createMenuItem(0.75f), + createMenuItem(1), }; this.backgroundDim = backgroundDim; diff --git a/osu.Game/Screens/Edit/BookmarkController.cs b/osu.Game/Screens/Edit/BookmarkController.cs index 80e77364e591..182e05a9284a 100644 --- a/osu.Game/Screens/Edit/BookmarkController.cs +++ b/osu.Game/Screens/Edit/BookmarkController.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Timing; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Localisation; @@ -106,8 +107,12 @@ private void removeClosestBookmark() private void seekBookmark(int direction) { + // In the case of a backwards seek while playing, it can be hard to jump before a bookmark. + // Adding some lenience here makes it more user-friendly. + double seekLenience = clock.IsRunning ? 1000 * ((IAdjustableClock)clock).Rate : 0; + int? targetBookmark = direction < 1 - ? bookmarks.Cast().LastOrDefault(b => b < clock.CurrentTimeAccurate) + ? bookmarks.Cast().LastOrDefault(b => b < clock.CurrentTimeAccurate - seekLenience) : bookmarks.Cast().FirstOrDefault(b => b > clock.CurrentTimeAccurate); if (targetBookmark != null) diff --git a/osu.Game/Screens/Edit/BookmarkResetDialog.cs b/osu.Game/Screens/Edit/BookmarkResetDialog.cs index 48a0202c8671..30a61c684a3f 100644 --- a/osu.Game/Screens/Edit/BookmarkResetDialog.cs +++ b/osu.Game/Screens/Edit/BookmarkResetDialog.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Game.Localisation; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Edit @@ -13,7 +14,7 @@ public partial class BookmarkResetDialog : DeletionDialog public BookmarkResetDialog(EditorBeatmap editorBeatmap) { editor = editorBeatmap; - BodyText = "All Bookmarks"; + BodyText = EditorDialogsStrings.AllBookmarks; } [BackgroundDependencyLoader] @@ -23,4 +24,3 @@ private void load() } } } - diff --git a/osu.Game/Screens/Edit/Components/FormSampleSet.cs b/osu.Game/Screens/Edit/Components/FormSampleSet.cs new file mode 100644 index 000000000000..370d36dd8cd9 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/FormSampleSet.cs @@ -0,0 +1,348 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Audio; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Components +{ + public partial class FormSampleSet : CompositeDrawable, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + public Func? SampleAddRequested { get; init; } + public Action? SampleRemoveRequested { get; init; } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + private readonly Dictionary<(string name, string bank), SampleButton> buttons = new Dictionary<(string, string), SampleButton>(); + private readonly Bindable lastSelectedFileDirectory = new Bindable(); + + private FormControlBackground background = null!; + private FormFieldCaption caption = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 5; + CornerExponent = 2.5f; + + InternalChildren = new Drawable[] + { + background = new FormControlBackground + { + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(9), + Spacing = new Vector2(7), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + caption = new FormFieldCaption(), + new GridContainer + { + AutoSizeAxes = Axes.Both, + RowDimensions = Enumerable.Repeat(new Dimension(GridSizeMode.AutoSize), 4).ToArray(), + ColumnDimensions = Enumerable.Repeat(new Dimension(GridSizeMode.AutoSize), 5).ToArray(), + Content = createTableContent().ToArray(), + } + }, + }, + }; + } + + private IEnumerable createTableContent() + { + string[] columns = HitSampleInfo.ALL_ADDITIONS.Prepend(HitSampleInfo.HIT_NORMAL).ToArray(); + string[] rows = HitSampleInfo.ALL_BANKS; + + yield return columns.Select(makeTableHeading).Prepend(Empty()).ToArray(); + + foreach (string row in rows) + { + List drawables = [makeTableHeading(row)]; + + foreach (string col in columns) + drawables.Add(buttons[(col, row)] = makeButton()); + + yield return drawables.ToArray(); + } + } + + private OsuSpriteText makeTableHeading(string text) => new OsuSpriteText + { + Text = text, + Font = OsuFont.Style.Caption1, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + + private SampleButton makeButton() => new SampleButton + { + Width = 60, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding(5), + SampleAddRequested = SampleAddRequested, + SampleRemoveRequested = SampleRemoveRequested, + LastSelectedFileDirectory = { BindTarget = lastSelectedFileDirectory }, + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateState(); + Current.BindValueChanged(setChanged, true); + } + + private void setChanged(ValueChangedEvent valueChangedEvent) + { + var set = valueChangedEvent.NewValue; + + caption.Caption = set?.Name ?? default(LocalisableString); + Alpha = set != null && set.SampleSetIndex > 0 ? 1 : 0; + + if (set != null) + { + foreach (var (sample, button) in buttons) + { + button.ExpectedFilename.Value = $@"{sample.bank}-{sample.name}{(set.SampleSetIndex > 1 ? set.SampleSetIndex : null)}"; + button.ActualFilename.Value = set.FindSampleIfExists(sample.name, sample.bank); + } + } + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + caption.Colour = colourProvider.Content2; + + background.VisualStyle = IsHovered ? VisualStyle.Hovered : VisualStyle.Normal; + } + + public partial class SampleButton : OsuButton, IHasPopover, IHasContextMenu + { + /// + /// The expected filename for the sample that this button represents. + /// Does not contain extension. + /// + public Bindable ExpectedFilename { get; } = new Bindable(); + + /// + /// The actual chosen filename for the sample that this button represent. + /// Can be if the sample is omitted / missing. + /// Does contain extension. + /// + public Bindable ActualFilename { get; } = new Bindable(); + + /// + /// Invoked when a new sample is selected via this button. + /// + public Func? SampleAddRequested { get; init; } + + /// + /// Invoked when a sample removal is selected via this button. + /// + public Action? SampleRemoveRequested { get; init; } + + private Bindable selectedFile { get; } = new Bindable(); + public Bindable LastSelectedFileDirectory { get; } = new Bindable(); + + private TrianglesV2? triangles { get; set; } + + protected override float HoverLayerFinalAlpha => 0; + + private Color4? triangleGradientSecondColour; + private SpriteIcon icon = null!; + + [Resolved] + private OverlayColourProvider overlayColourProvider { get; set; } = null!; + + [Resolved] + private EditorBeatmap? editorBeatmap { get; set; } + + private HoverSounds? hoverSounds; + + private ISample? sample; + + public SampleButton() + : base(null) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Add(icon = new SpriteIcon + { + Icon = FontAwesome.Solid.Plus, + Size = new Vector2(16), + Shadow = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Action = () => + { + if (ActualFilename.Value == null) + { + selectedFile.Value = null; + this.ShowPopover(); + } + else + sample?.Play(); + }; + + if (editorBeatmap?.BeatmapSkin != null) + editorBeatmap.BeatmapSkin.BeatmapSkinChanged += recycleSamples; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Content.CornerRadius = 4; + + Add(triangles = new TrianglesV2 + { + Thickness = 0.02f, + SpawnRatio = 0.6f, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + }); + + ActualFilename.BindValueChanged(_ => updateState(), true); + selectedFile.BindValueChanged(_ => addSample()); + } + + private void updateState() + { + BackgroundColour = ActualFilename.Value == null ? overlayColourProvider.Background3 : overlayColourProvider.Colour3; + triangleGradientSecondColour = BackgroundColour.Lighten(0.2f); + icon.Icon = ActualFilename.Value == null ? FontAwesome.Solid.Plus : FontAwesome.Solid.Play; + + recycleSamples(); + + if (triangles == null) + return; + + triangles.Colour = ColourInfo.GradientVertical(triangleGradientSecondColour.Value, BackgroundColour); + } + + private void recycleSamples() => Schedule(() => + { + if (hoverSounds?.Parent == this) + { + RemoveInternal(hoverSounds, true); + hoverSounds = null; + } + + AddInternal(hoverSounds = (ActualFilename.Value == null ? new HoverClickSounds(HoverSampleSet.Button) : new HoverSounds(HoverSampleSet.Button))); + + sample = ActualFilename.Value != null ? editorBeatmap?.BeatmapSkin?.Skin.Samples?.Get(ActualFilename.Value) : null; + }); + + protected override bool OnHover(HoverEvent e) + { + Debug.Assert(triangleGradientSecondColour != null); + + Background.FadeColour(triangleGradientSecondColour.Value, 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Background.FadeColour(BackgroundColour, 300, Easing.OutQuint); + base.OnHoverLost(e); + } + + private void addSample() + { + if (selectedFile.Value == null) + return; + + this.HidePopover(); + ActualFilename.Value = SampleAddRequested?.Invoke(selectedFile.Value, ExpectedFilename.Value) ?? selectedFile.Value.ToString(); + LastSelectedFileDirectory.Value = selectedFile.Value.Directory; + } + + private void deleteSample() + { + if (ActualFilename.Value == null) + return; + + SampleRemoveRequested?.Invoke(ActualFilename.Value); + ActualFilename.Value = null; + } + + public Popover? GetPopover() => ActualFilename.Value == null + ? new FormFileSelector.FileChooserPopover(SupportedExtensions.AUDIO_EXTENSIONS, selectedFile, LastSelectedFileDirectory.Value?.FullName) + : null; + + public MenuItem[]? ContextMenuItems => + ActualFilename.Value != null + ? [new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, deleteSample)] + : null; + + protected override void Dispose(bool isDisposing) + { + if (editorBeatmap?.BeatmapSkin != null) + editorBeatmap.BeatmapSkin.BeatmapSkinChanged -= recycleSamples; + base.Dispose(isDisposing); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index 7b36b5f957e1..f9c6ee6b4363 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -49,7 +49,8 @@ public required LocalisableString Description public Drawable Icon { get; private set; } = null!; - public DrawableTernaryButton() + public DrawableTernaryButton(HoverSampleSet? hoverSampleSet = HoverSampleSet.Button) + : base(hoverSampleSet) { RelativeSizeAxes = Axes.X; } diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/SampleSetTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleSetTernaryButton.cs new file mode 100644 index 000000000000..96227ec53e3a --- /dev/null +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleSetTernaryButton.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Edit.Components.TernaryButtons +{ + public partial class SampleSetTernaryButton : DrawableTernaryButton + { + public EditorBeatmapSkin.SampleSet SampleSet { get; } + + public SampleSetTernaryButton(EditorBeatmapSkin.SampleSet sampleSet) + : base(null) + { + SampleSet = sampleSet; + CreateIcon = () => sampleSet.SampleSetIndex == 0 + ? new SpriteIcon { Icon = OsuIcon.SkinA } + : new Container + { + Child = new OsuSpriteText + { + Text = sampleSet.SampleSetIndex.ToString(), + Font = OsuFont.Style.Body.With(weight: FontWeight.Bold), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + + switch (sampleSet.SampleSetIndex) + { + case 0: + RelativeSizeAxes = Axes.X; + Width = 1; + break; + + default: + RelativeSizeAxes = Axes.None; + Width = Height; + break; + } + } + + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + new HoverSounds(HoverSampleSet.Button), + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs index d17f9011f4e1..7de94cd22e80 100644 --- a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs +++ b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs @@ -151,7 +151,7 @@ private void load() }); }; - inputTextBox.Current.BindValueChanged(val => editor?.HandleTimestamp(val.NewValue)); + inputTextBox.Current.BindValueChanged(val => editor?.HandleTimestamp(val.NewValue.Trim())); inputTextBox.OnCommit += (_, __) => { diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs index afe14de3eab2..1fedd6f589af 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs @@ -5,7 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input.Events; -using osu.Framework.Threading; +using osu.Game.Overlays; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK; @@ -34,39 +34,61 @@ private void load() }); } + private double? lastSeekTime; + protected override bool OnDragStart(DragStartEvent e) => true; protected override void OnDrag(DragEvent e) { - seekToPosition(e.ScreenSpaceMousePosition); + base.OnDrag(e); + seekToPosition(e.ScreenSpaceMousePosition, instant: false); + } + + protected override void OnDragEnd(DragEndEvent e) + { + base.OnDragEnd(e); + seekToPosition(e.ScreenSpaceMousePosition, instant: true); } protected override bool OnMouseDown(MouseDownEvent e) { - seekToPosition(e.ScreenSpaceMousePosition); + seekToPosition(e.ScreenSpaceMousePosition, instant: true); return true; } - private ScheduledDelegate? scheduledSeek; - /// /// Seeks the to the time closest to a position on the screen relative to the . /// /// The position in screen coordinates. - private void seekToPosition(Vector2 screenPosition) + /// Whether the seek should be instant (drag end, mouse button press) or debounced (drag in progress). + private void seekToPosition(Vector2 screenPosition, bool instant) { - scheduledSeek?.Cancel(); - scheduledSeek = Schedule(() => + // Debounce seeks to ensure we only run one per update frame at most. + // + // Without this, we could end up seeking 1000+ times per second, leading to + // unexpected performance overheads as the editor tries to prepare for displaying + // each of the destinations. + Scheduler.AddOnce(data => { - float markerPos = Math.Clamp(ToLocalSpace(screenPosition).X, 0, DrawWidth); - editorClock.SeekSmoothlyTo(markerPos / DrawWidth * editorClock.TrackLength); - }); + float markerPos = Math.Clamp(ToLocalSpace(data.screenPosition).X, 0, DrawWidth); + double seekDestination = markerPos / DrawWidth * editorClock.TrackLength; + marker.X = (float)seekDestination; + + if (editorClock.IsRunning && !data.instant && lastSeekTime != null && Time.Current - lastSeekTime < NowPlayingOverlay.TRACK_DRAG_SEEK_DEBOUNCE) + return; + + editorClock.Seek(seekDestination); + + lastSeekTime = data.instant ? null : Time.Current; + }, (screenPosition, instant)); } protected override void Update() { base.Update(); - marker.X = (float)editorClock.CurrentTime; + + if (!IsDragged) + marker.X = (float)editorClock.CurrentTime; } protected override void LoadBeatmap(EditorBeatmap beatmap) diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 4414e963bf2a..eff74bd55663 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -100,7 +100,6 @@ protected override void LoadComplete() kvp.Value.BindValueChanged(_ => updatePlacementSamples()); SelectionHandler.AutoSelectionBankEnabled.BindValueChanged(_ => updateAutoBankTernaryButtonTooltip(), true); - SelectionHandler.SelectionAdditionBanksEnabled.BindValueChanged(_ => updateAdditionBankTernaryButtonTooltips(), true); } protected override void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject) @@ -231,7 +230,7 @@ public static Drawable GetIconForSample(string sampleName) switch (sampleName) { case HitSampleInfo.HIT_CLAP: - return new SpriteIcon { Icon = FontAwesome.Solid.Hands }; + return new SpriteIcon { Icon = OsuIcon.EditorClap }; case HitSampleInfo.HIT_WHISTLE: return new SpriteIcon { Icon = OsuIcon.EditorWhistle }; @@ -252,17 +251,6 @@ private void updateAutoBankTernaryButtonTooltip() autoBankButton.NormalButton.TooltipText = !enabled ? "Auto normal bank can only be used during hit object placement" : string.Empty; } - private void updateAdditionBankTernaryButtonTooltips() - { - bool enabled = SelectionHandler.SelectionAdditionBanksEnabled.Value; - - foreach (var ternaryButton in SampleBankTernaryStates) - { - ternaryButton.AdditionsButton.Enabled.Value = enabled; - ternaryButton.AdditionsButton.TooltipText = !enabled ? "Add an addition sample first to be able to set a bank" : string.Empty; - } - } - #region Placement /// diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs b/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs index 5837dd794627..7aaa2e7f65f3 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs @@ -35,14 +35,14 @@ private void load() protected void AddHeader(string header) => InspectorText.AddParagraph($"{header}: ", s => { - s.Padding = new MarginPadding { Top = 2 }; - s.Font = s.Font.With(size: 12); + s.Font = OsuFont.Style.Caption1; s.Colour = colourProvider.Content2; }); protected void AddValue(string value) => InspectorText.AddParagraph(value, s => { - s.Font = s.Font.With(weight: FontWeight.SemiBold); + s.Padding = new MarginPadding { Top = -5 }; + s.Font = OsuFont.Style.Body; s.Colour = colourProvider.Content1; }); } diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index a258016da5a8..9bee0a4fffd5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -16,6 +16,7 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components @@ -84,11 +85,6 @@ protected override bool ShouldQuickDelete(MouseButtonEvent e) /// public readonly Bindable AutoSelectionBankEnabled = new Bindable(); - /// - /// Whether the selection contains any addition samples and the can be used. - /// - public readonly Bindable SelectionAdditionBanksEnabled = new Bindable(); - /// /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) /// @@ -200,26 +196,18 @@ private void createStateBindables() break; case TernaryState.True: - if (SelectedItems.Count == 0) - { - // Ensure the user can't stack multiple bank selections when there's no hitobject selection. - // Note that in normal scenarios this is sorted out by the feedback from applying the bank to the selected objects. - foreach (var other in SelectionAdditionBankStates.Values) - { - if (other != bindable) - other.Value = TernaryState.False; - } - } - else - { - // If none of the selected objects have any addition samples, we should not apply the addition bank. - if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.All(o => o.Name == HitSampleInfo.HIT_NORMAL))) - { - bindable.Value = TernaryState.False; - break; - } - + // If any of the selected objects have any addition samples, we should apply the addition bank. + if (SelectedItems.SelectMany(enumerateAllSamples).Any(h => h.Any(o => o.Name != HitSampleInfo.HIT_NORMAL))) SetSampleAdditionBank(bankName); + + // There are either no selected items, or none of the selected items have addition sounds. + // This state is basically the user pre-selecting an addition bank before actually adding an addition. + // Ensure the user can't stack multiple bank selections in this state. + // Note that in normal scenarios this is sorted out by the feedback from applying the bank to the selected objects. + foreach (var other in SelectionAdditionBankStates.Values) + { + if (other != bindable) + other.Value = TernaryState.False; } break; @@ -278,7 +266,6 @@ private void resetTernaryStates() SelectionNewComboState.Value = TernaryState.False; AutoSelectionBankEnabled.Value = true; - SelectionAdditionBanksEnabled.Value = true; SelectionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; SelectionAdditionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; foreach (var (_, sampleState) in SelectionSampleStates) @@ -296,22 +283,30 @@ protected virtual void UpdateTernaryStates() var samplesInSelection = SelectedItems.SelectMany(enumerateAllSamples).ToArray(); - foreach ((string sampleName, var bindable) in SelectionSampleStates) - { - bindable.Value = GetStateFromSelection(samplesInSelection, h => h.Any(s => s.Name == sampleName)); - } - - foreach ((string bankName, var bindable) in SelectionBankStates) + if (samplesInSelection.Length > 0) { - bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name == HitSampleInfo.HIT_NORMAL), h => h.Bank == bankName); - } + foreach ((string sampleName, var bindable) in SelectionSampleStates) + { + bindable.Value = GetStateFromSelection(samplesInSelection, h => h.Any(s => s.Name == sampleName)); + } - SelectionAdditionBanksEnabled.Value = samplesInSelection.SelectMany(s => s).Any(o => o.Name != HitSampleInfo.HIT_NORMAL); + foreach ((string bankName, var bindable) in SelectionBankStates) + { + bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name == HitSampleInfo.HIT_NORMAL), h => h.Bank == bankName); + } - foreach ((string bankName, var bindable) in SelectionAdditionBankStates) - { - bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), - h => (bankName != HIT_BANK_AUTO && h.Bank == bankName && !h.EditorAutoBank) || (bankName == HIT_BANK_AUTO && h.EditorAutoBank)); + // if there are no addition samples in the selection, do not touch the state of addition bank bindables. + // this is to reduce annoyance from the bank resetting if the user wants to e.g. remove the only addition sound on an object, but then add another addition sound + // while keeping the bank the same. + // note that deselecting all objects will still reset the addition bank selection to auto via `ResetTernaryStates()`. this may need to be reconsidered later. + if (samplesInSelection.SelectMany(s => s).Any(o => o.Name != HitSampleInfo.HIT_NORMAL)) + { + foreach ((string bankName, var bindable) in SelectionAdditionBankStates) + { + bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), + h => (bankName != HIT_BANK_AUTO && h.Bank == bankName && !h.EditorAutoBank) || (bankName == HIT_BANK_AUTO && h.EditorAutoBank)); + } + } } } @@ -342,6 +337,9 @@ private IEnumerable> enumerateAllSamples(HitObject hitObjec /// /// Sets the sample bank for all selected s. /// + /// + /// Should be kept in sync with . + /// /// The name of the sample bank. public void SetSampleBank(string bankName) { @@ -366,12 +364,12 @@ bool hasRelevantBank(HitObject hitObject) if (hasRelevantBank(h)) return; - h.Samples = h.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); + h.Samples = h.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL || s.EditorAutoBank ? s.With(newBank: bankName) : s).ToList(); if (h is IHasRepeats hasRepeats) { for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) - hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name == HitSampleInfo.HIT_NORMAL || s.EditorAutoBank ? s.With(newBank: bankName) : s).ToList(); } }); } @@ -379,6 +377,9 @@ bool hasRelevantBank(HitObject hitObject) /// /// Sets the sample addition bank for all selected s. /// + /// + /// Should be kept in sync with . + /// /// The name of the sample bank. public void SetSampleAdditionBank(string bankName) { @@ -432,6 +433,9 @@ private bool hasRelevantSample(HitObject hitObject, string sampleName) /// /// Adds a hit sample to all selected s. /// + /// + /// Should be kept in sync with . + /// /// The name of the hit sample. public void AddHitSample(string sampleName) { @@ -440,9 +444,24 @@ public void AddHitSample(string sampleName) EditorBeatmap.PerformOnSelection(h => { + string? forcedBank = null; + // if the selected object(s) only have normal samples, check whether the user has preselected a singular non-auto bank using `SelectionAdditionBankStates`. + // other scenarios are already handled by `CreateHitSampleInfo()`: + // - if the selected object(s) already have addition samples, `CreateHitSampleInfo()` will copy the bank from said addition samples. + // - if the selected object(s) do not have addition samples but the user has preselected auto bank, `CreateHitSampleInfo()` will use the auto bank anyway. + if (h.Samples.All(s => s.Name == HitSampleInfo.HIT_NORMAL)) + forcedBank = SelectionAdditionBankStates.SingleOrDefault(kv => kv.Value.Value == TernaryState.True).Key; + // Make sure there isn't already an existing sample if (h.Samples.All(s => s.Name != sampleName)) - h.Samples.Add(h.CreateHitSampleInfo(sampleName)); + { + var hitSample = h.CreateHitSampleInfo(sampleName); + + if (forcedBank != null && forcedBank != HIT_BANK_AUTO) + hitSample = hitSample.With(newBank: forcedBank, newEditorAutoBank: false); + + h.Samples.Add(hitSample); + } if (h is IHasRepeats hasRepeats) { diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index 2171ba696fc8..40eb1e66c7c1 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -53,7 +53,7 @@ public bool CanReverse if (canReverse == value) return; canReverse = value; - recreate(); + recreateButtons(); } } @@ -78,7 +78,7 @@ public bool CanFlipX if (canFlipX == value) return; canFlipX = value; - recreate(); + recreateButtons(); } } @@ -95,7 +95,7 @@ public bool CanFlipY if (canFlipY == value) return; canFlipY = value; - recreate(); + recreateButtons(); } } @@ -116,7 +116,7 @@ public string Text } private SelectionBoxDragHandleContainer dragHandles = null!; - private FillFlowContainer buttons = null!; + private FillFlowContainer buttons = null!; private OsuSpriteText? selectionDetailsText; @@ -126,6 +126,60 @@ public string Text [BackgroundDependencyLoader] private void load() { + 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, + BorderThickness = BORDER_RADIUS, + BorderColour = colours.YellowDark, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + + AlwaysPresent = true, + Alpha = 0 + }, + } + }, + dragHandles = new SelectionBoxDragHandleContainer + { + RelativeSizeAxes = Axes.Both, + // ensures that the centres of all drag handles line up with the middle of the selection box border. + Padding = new MarginPadding(BORDER_RADIUS / 2) + }, + buttons = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + Height = 30, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding(button_padding), + } + }; + if (rotationHandler != null) canRotate.BindTo(rotationHandler.CanRotateAroundSelectionOrigin); @@ -136,10 +190,14 @@ private void load() canScaleDiagonally.BindTo(scaleHandler.CanScaleDiagonally); } - canRotate.BindValueChanged(_ => recreate()); - canScaleX.BindValueChanged(_ => recreate()); - canScaleY.BindValueChanged(_ => recreate()); - canScaleDiagonally.BindValueChanged(_ => recreate(), true); + canScaleX.BindValueChanged(_ => recreateScaleHandles()); + canScaleY.BindValueChanged(_ => recreateScaleHandles()); + canScaleDiagonally.BindValueChanged(_ => recreateScaleHandles(), true); + canRotate.BindValueChanged(_ => + { + recreateRotationHandles(); + recreateButtons(); + }, true); } protected override bool OnKeyDown(KeyDownEvent e) @@ -181,113 +239,95 @@ protected override void Update() ensureButtonsOnScreen(); } - private void recreate() + private void recreateScaleHandles() { if (LoadState < LoadState.Loading) return; - InternalChildren = new Drawable[] + dragHandles.ClearScaleHandles(); + + if (canScaleY.Value) { - 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, - BorderThickness = BORDER_RADIUS, - BorderColour = colours.YellowDark, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, + addScaleHandle(Anchor.TopCentre); + addScaleHandle(Anchor.BottomCentre); + } - AlwaysPresent = true, - Alpha = 0 - }, - } - }, - dragHandles = new SelectionBoxDragHandleContainer - { - RelativeSizeAxes = Axes.Both, - // ensures that the centres of all drag handles line up with the middle of the selection box border. - Padding = new MarginPadding(BORDER_RADIUS / 2) - }, - buttons = new FillFlowContainer - { - AutoSizeAxes = Axes.X, - Height = 30, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding(button_padding), - } - }; + if (canScaleDiagonally.Value) + { + addScaleHandle(Anchor.TopLeft); + addScaleHandle(Anchor.TopRight); + addScaleHandle(Anchor.BottomLeft); + addScaleHandle(Anchor.BottomRight); + } - if (canScaleX.Value) addXScaleComponents(); - if (canScaleDiagonally.Value) addFullScaleComponents(); - if (canScaleY.Value) addYScaleComponents(); - if (CanFlipX) addXFlipComponents(); - if (CanFlipY) addYFlipComponents(); - if (canRotate.Value) addRotationComponents(); - if (CanReverse) reverseButton = addButton(FontAwesome.Solid.Backward, "Reverse pattern (Ctrl-G)", () => OnReverse?.Invoke()); + if (canScaleX.Value) + { + addScaleHandle(Anchor.CentreLeft); + addScaleHandle(Anchor.CentreRight); + } } - private void addRotationComponents() + private void addScaleHandle(Anchor anchor) { - rotateCounterClockwiseButton = addButton(FontAwesome.Solid.Undo, "Rotate 90 degrees counter-clockwise (Ctrl-<)", () => rotationHandler?.Rotate(-90)); - rotateClockwiseButton = addButton(FontAwesome.Solid.Redo, "Rotate 90 degrees clockwise (Ctrl->)", () => rotationHandler?.Rotate(90)); + var handle = new SelectionBoxScaleHandle + { + Anchor = anchor, + }; - addRotateHandle(Anchor.TopLeft); - addRotateHandle(Anchor.TopRight); - addRotateHandle(Anchor.BottomLeft); - addRotateHandle(Anchor.BottomRight); + handle.OperationStarted += operationStarted; + handle.OperationEnded += operationEnded; + dragHandles.AddScaleHandle(handle); } - private void addYScaleComponents() + private void recreateRotationHandles() { - addScaleHandle(Anchor.TopCentre); - addScaleHandle(Anchor.BottomCentre); - } + if (LoadState < LoadState.Loading) + return; - private void addFullScaleComponents() - { - addScaleHandle(Anchor.TopLeft); - addScaleHandle(Anchor.TopRight); - addScaleHandle(Anchor.BottomLeft); - addScaleHandle(Anchor.BottomRight); - } + dragHandles.ClearRotationHandles(); - private void addXScaleComponents() - { - addScaleHandle(Anchor.CentreLeft); - addScaleHandle(Anchor.CentreRight); + if (canRotate.Value) + { + addRotateHandle(Anchor.TopLeft); + addRotateHandle(Anchor.TopRight); + addRotateHandle(Anchor.BottomLeft); + addRotateHandle(Anchor.BottomRight); + } } - private void addXFlipComponents() + private void addRotateHandle(Anchor anchor) { - addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally", () => OnFlip?.Invoke(Direction.Horizontal, false)); + var handle = new SelectionBoxRotationHandle + { + Anchor = anchor, + }; + + handle.OperationStarted += operationStarted; + handle.OperationEnded += operationEnded; + dragHandles.AddRotationHandle(handle); } - private void addYFlipComponents() + private void recreateButtons() { - addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically", () => OnFlip?.Invoke(Direction.Vertical, false)); + if (LoadState < LoadState.Loading) + return; + + clearButtons(); + + if (canRotate.Value) + { + rotateCounterClockwiseButton = addButton(FontAwesome.Solid.Undo, "Rotate 90 degrees counter-clockwise (Ctrl-<)", () => rotationHandler?.Rotate(-90)); + rotateClockwiseButton = addButton(FontAwesome.Solid.Redo, "Rotate 90 degrees clockwise (Ctrl->)", () => rotationHandler?.Rotate(90)); + } + + if (CanFlipX) + addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally", () => OnFlip?.Invoke(Direction.Horizontal, false)); + + if (CanFlipY) + addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically", () => OnFlip?.Invoke(Direction.Vertical, false)); + + if (CanReverse) + reverseButton = addButton(FontAwesome.Solid.Backward, "Reverse pattern (Ctrl-G)", () => OnReverse?.Invoke()); } private SelectionBoxButton addButton(IconUsage icon, string tooltip, Action action) @@ -308,6 +348,21 @@ private SelectionBoxButton addButton(IconUsage icon, string tooltip, Action acti return button; } + private void clearButtons() + { + foreach (var button in buttons) + { + button.Clicked -= freezeButtonPosition; + button.HoverLost -= unfreezeButtonPosition; + + button.OperationStarted -= operationStarted; + button.OperationEnded -= operationEnded; + } + + unfreezeButtonPosition(); + buttons.Clear(); + } + /// /// This method should be called when a selection needs to be flipped /// because of an ongoing scale handle drag that would otherwise cause width or height to go negative. @@ -327,41 +382,8 @@ public void PerformFlipFromScaleHandles(Axes axes) } } - private void addScaleHandle(Anchor anchor) - { - var handle = new SelectionBoxScaleHandle - { - Anchor = anchor, - }; - - handle.OperationStarted += operationStarted; - handle.OperationEnded += operationEnded; - dragHandles.AddScaleHandle(handle); - } - - private void addRotateHandle(Anchor anchor) - { - var handle = new SelectionBoxRotationHandle - { - Anchor = anchor, - }; - - handle.OperationStarted += operationStarted; - handle.OperationEnded += operationEnded; - dragHandles.AddRotationHandle(handle); - } - private int activeOperations; - private float convertDragEventToAngleOfRotation(DragEvent e) - { - // Adjust coordinate system to the center of SelectionBox - float startAngle = MathF.Atan2(e.LastMousePosition.Y - DrawHeight / 2, e.LastMousePosition.X - DrawWidth / 2); - float endAngle = MathF.Atan2(e.MousePosition.Y - DrawHeight / 2, e.MousePosition.X - DrawWidth / 2); - - return (endAngle - startAngle) * 180 / MathF.PI; - } - private void operationEnded() { if (--activeOperations == 0) diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs index e5ac05ca6a37..d927efef83ee 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs @@ -51,6 +51,13 @@ public void AddScaleHandle(SelectionBoxScaleHandle handle) scaleHandles.Add(handle); } + public void ClearScaleHandles() + { + foreach (var scaleHandle in scaleHandles) + unbindDragHandle(scaleHandle); + scaleHandles.Clear(); + } + public void AddRotationHandle(SelectionBoxRotationHandle handle) { handle.Alpha = 0; @@ -60,6 +67,13 @@ public void AddRotationHandle(SelectionBoxRotationHandle handle) rotationHandles.Add(handle); } + public void ClearRotationHandles() + { + foreach (var rotationHandle in rotationHandles) + unbindDragHandle(rotationHandle); + rotationHandles.Clear(); + } + private void bindDragHandle(SelectionBoxDragHandle handle) { handle.HoverGained += updateRotationHandlesVisibility; @@ -69,6 +83,15 @@ private void bindDragHandle(SelectionBoxDragHandle handle) allDragHandles.Add(handle); } + private void unbindDragHandle(SelectionBoxDragHandle handle) + { + handle.HoverGained -= updateRotationHandlesVisibility; + handle.HoverLost -= updateRotationHandlesVisibility; + handle.MouseDown -= updateRotationHandlesVisibility; + handle.MouseUp -= updateRotationHandlesVisibility; + allDragHandles.Remove(handle); + } + public void FlipScaleHandles(Direction direction) { foreach (var handle in scaleHandles) diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index cdd2f52dab5d..c2def9506893 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -20,10 +20,11 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets.Objects; -using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Timing; +using osu.Game.Skinning; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -127,7 +128,7 @@ protected override bool OnClick(ClickEvent e) private void updateText() { - Label.Text = $"{abbreviateBank(GetBankValue(GetSamples()))} {GetVolumeValue(GetSamples())}"; + Label.Text = $"{abbreviateBank(GetBankValue(GetSamples()))}{GetSuffix(GetSamples())} {GetVolumeValue(GetSamples())}"; if (!contracted.Value) LabelContainer.ResizeWidthTo(Label.Width, 200, Easing.OutQuint); @@ -149,6 +150,17 @@ private void updateText() return samples.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL)?.Bank; } + public static string GetSuffix(IEnumerable samples) + { + var suffixes = samples.Select(o => o.Suffix).Distinct().ToList(); + + // having multiple values should never happen, but just for safety... + if (suffixes.Count != 1 || suffixes.Single() is not string commonSuffix) + return string.Empty; + + return $@":{commonSuffix}"; + } + public static string? GetAdditionBankValue(IEnumerable samples) { var firstAddition = samples.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL); @@ -176,9 +188,12 @@ public partial class SampleEditPopover : OsuPopover { private readonly HitObject hitObject; - private LabelledTextBox bank = null!; - private LabelledTextBox additionBank = null!; + private LabelledDropdown bank = null!; + private LabelledDropdown additionBank = null!; + private FillFlowContainer? sampleSetsFlow; + private LabelledDropdown? sampleSetDropdown; private IndeterminateSliderWithTextBoxInput volume = null!; + private SkinnableSound demoSample = null!; private FillFlowContainer togglesCollection = null!; @@ -229,11 +244,11 @@ private void load() { flow = new FillFlowContainer { - Width = 200, + Width = 220, Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, Spacing = new Vector2(0, 10), - Children = new Drawable[] + Children = new[] { togglesCollection = new FillFlowContainer { @@ -242,27 +257,30 @@ private void load() Direction = FillDirection.Horizontal, Spacing = new Vector2(5, 5), }, - bank = new LabelledTextBox + bank = new LabelledDropdown(padded: false) { - Label = "Bank Name", - SelectAllOnFocus = true, + Label = "Normal Bank", + Items = HitSampleInfo.ALL_BANKS, }, - additionBank = new LabelledTextBox + additionBank = new LabelledDropdown(padded: false) { Label = "Addition Bank", - SelectAllOnFocus = true, + Items = HitSampleInfo.ALL_BANKS, }, + createSampleSetContent(), volume = new IndeterminateSliderWithTextBoxInput("Volume", new BindableInt(100) { MinValue = DrawableHitObject.MINIMUM_SAMPLE_VOLUME, MaxValue = 100, }) } + }, + new EditorSkinProvidingContainer(beatmap) + { + Child = demoSample = new SkinnableSound() } }; - bank.TabbableContentContainer = flow; - additionBank.TabbableContentContainer = flow; volume.TabbableContentContainer = flow; // if the piece belongs to a currently selected object, assume that the user wants to change all selected objects. @@ -283,10 +301,8 @@ private void load() setBank(val.NewValue); updatePrimaryBankState(); + playDemoSample(); }); - // on commit, ensure that the value is correct by sourcing it from the objects' samples again. - // this ensures that committing empty text causes a revert to the previous value. - bank.OnCommit += (_, _) => updatePrimaryBankState(); updateAdditionBankState(); additionBank.Current.BindValueChanged(val => @@ -296,8 +312,10 @@ private void load() setAdditionBank(val.NewValue); updateAdditionBankState(); + playDemoSample(); }); - additionBank.OnCommit += (_, _) => updateAdditionBankState(); + + updateSampleSetState(); volume.Current.BindValueChanged(val => { @@ -310,6 +328,58 @@ private void load() togglesCollection.AddRange(createTernaryButtons()); } + private Drawable createSampleSetContent() + { + if (beatmap.BeatmapSkin == null) + return Empty(); + + var sampleSets = beatmap.BeatmapSkin.GetAvailableSampleSets().ToList(); + + if (sampleSets.Count == 0) + return Empty(); + + sampleSets.Insert(0, new EditorBeatmapSkin.SampleSet(0, "User skin")); + + if (sampleSets.Count < 20) + { + sampleSetsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(5), + ChildrenEnumerable = sampleSets.Select(set => new SampleSetTernaryButton(set) { Description = set.Name }), + }; + + foreach (var ternary in sampleSetsFlow) + { + ternary.Current.BindValueChanged(val => + { + if (val.NewValue == TernaryState.True) + setSampleSet(ternary.SampleSet); + + updateSampleSetState(); + playDemoSample(); + }); + } + + return sampleSetsFlow; + } + + sampleSetDropdown = new LabelledDropdown(padded: false) + { + Label = "Sample Set", + Items = sampleSets, + }; + sampleSetDropdown.Current.BindValueChanged(val => + { + setSampleSet(val.NewValue); + updateSampleSetState(); + playDemoSample(); + }); + + return sampleSetDropdown; + } + private string? getCommonBank() => allRelevantSamples.Select(h => GetBankValue(h.samples)).Distinct().Count() == 1 ? GetBankValue(allRelevantSamples.First().samples) : null; @@ -327,15 +397,13 @@ private void load() private void updatePrimaryBankState() { string? commonBank = getCommonBank(); - bank.Current.Value = commonBank; - bank.PlaceholderText = string.IsNullOrEmpty(commonBank) ? "(multiple)" : string.Empty; + bank.Current.Value = !string.IsNullOrEmpty(commonBank) ? commonBank : "(multiple)"; } private void updateAdditionBankState() { string? commonAdditionBank = getCommonAdditionBank(); - additionBank.PlaceholderText = string.IsNullOrEmpty(commonAdditionBank) ? "(multiple)" : string.Empty; - additionBank.Current.Value = commonAdditionBank; + additionBank.Current.Value = !string.IsNullOrEmpty(commonAdditionBank) ? commonAdditionBank : "(multiple)"; bool anyAdditions = allRelevantSamples.Any(o => o.samples.Any(s => s.Name != HitSampleInfo.HIT_NORMAL)); if (anyAdditions) @@ -344,6 +412,40 @@ private void updateAdditionBankState() additionBank.Hide(); } + private void updateSampleSetState() + { + HashSet activeSets = new HashSet(); + + foreach (var sample in allRelevantSamples.SelectMany(h => h.samples)) + { + if (sample.Suffix == null) + activeSets.Add(sample.UseBeatmapSamples ? 1 : 0); + else if (int.TryParse(sample.Suffix, out int suffix)) + activeSets.Add(suffix); + } + + if (sampleSetsFlow != null) + { + var onState = activeSets.Count > 1 ? TernaryState.Indeterminate : TernaryState.True; + + foreach (var ternary in sampleSetsFlow) + ternary.Current.Value = activeSets.Contains(ternary.SampleSet.SampleSetIndex) ? onState : TernaryState.False; + } + + if (sampleSetDropdown != null) + { + sampleSetDropdown.Current.Value = activeSets.Count == 1 + ? sampleSetDropdown.Items.Single(i => i.SampleSetIndex == activeSets.Single()) + : new EditorBeatmapSkin.SampleSet(-1, "(multiple)"); + } + } + + private void playDemoSample() => Scheduler.AddOnce(() => + { + demoSample.Samples = allRelevantSamples.First().samples.Cast().ToArray(); + demoSample.Play(); + }); + /// /// Applies the given update action on all samples of /// and invokes the necessary update notifiers for the beatmap and hit objects. @@ -362,6 +464,9 @@ private void updateAllRelevantSamples(Action> up beatmap.EndChange(); } + /// + /// Should be kept in sync with . + /// private void setBank(string newBank) { updateAllRelevantSamples((_, relevantSamples) => @@ -375,6 +480,9 @@ private void setBank(string newBank) }); } + /// + /// Should be kept in sync with . + /// private void setAdditionBank(string newBank) { updateAllRelevantSamples((_, relevantSamples) => @@ -397,6 +505,19 @@ private void setAdditionBank(string newBank) }); } + private void setSampleSet(EditorBeatmapSkin.SampleSet newSampleSet) + { + updateAllRelevantSamples((_, relevantSamples) => + { + for (int i = 0; i < relevantSamples.Count; i++) + { + relevantSamples[i] = relevantSamples[i].With( + newSuffix: newSampleSet.SampleSetIndex >= 2 ? newSampleSet.SampleSetIndex.ToString() : null, + newUseBeatmapSamples: newSampleSet.SampleSetIndex >= 1); + } + }); + } + private void setVolume(int newVolume) { updateAllRelevantSamples((_, relevantSamples) => @@ -435,6 +556,8 @@ private void createStateBindables() addHitSample(sampleName); break; } + + playDemoSample(); }; selectionSampleStates[sampleName] = bindable; @@ -455,7 +578,7 @@ private IEnumerable createTernaryButtons() { foreach ((string sampleName, var bindable) in selectionSampleStates) { - yield return new DrawableTernaryButton + yield return new DrawableTernaryButton(null) { Current = bindable, Description = string.Empty, @@ -466,6 +589,9 @@ private IEnumerable createTernaryButtons() } } + /// + /// Should be kept in sync with . + /// private void addHitSample(string sampleName) { if (string.IsNullOrEmpty(sampleName)) diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index f60d1b023b5b..9f028009936d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -312,6 +313,8 @@ public partial class DragArea : Circle { private readonly HitObject? hitObject; + private readonly List objsToAdjust = new List(); + [Resolved] private EditorBeatmap beatmap { get; set; } = null!; @@ -404,6 +407,23 @@ private void updateState() protected override bool OnDragStart(DragStartEvent e) { changeHandler?.BeginChange(); + + var selectionItems = beatmap.SelectedHitObjects; + + if (!selectionItems.Contains(hitObject)) + return true; + + foreach (var item in selectionItems) + { + if (item == hitObject || item is not IHasDuration durationItem) continue; + + if (Precision.AlmostEquals(durationItem.Duration, (hitObject as IHasDuration)!.Duration, 1) && + Precision.AlmostEquals(item.StartTime, hitObject!.StartTime, 1)) + { + objsToAdjust.Add(item); + } + } + return true; } @@ -456,8 +476,15 @@ protected override void OnDrag(DragEvent e) if (endTimeHitObject.EndTime == snappedTime) return; - endTimeHitObject.Duration = snappedTime - hitObject.StartTime; - beatmap.Update(hitObject); + if (!objsToAdjust.Contains(hitObject)) + objsToAdjust.Add(hitObject); + + foreach (var obj in objsToAdjust) + { + (obj as IHasDuration)!.Duration = snappedTime - obj.StartTime; + beatmap.Update(obj); + } + break; } } @@ -473,6 +500,7 @@ protected override void OnDragEnd(DragEndEvent e) changeHandler?.EndChange(); OnDragHandled?.Invoke(null); + objsToAdjust.Clear(); } } diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 195625dcdece..00690c617e01 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -138,6 +138,8 @@ public override void Copy() // regardless of whether anything was even selected at all. // UX-wise this is generally strange and unexpected, but make it work anyways to preserve muscle memory. // note that this means that `getTimestamp()` must handle no-selection case, too. + // additionally, note we're intentionally not using `OsuGame.CopyToClipboard()` + // because we do not want toasts to pop up on every Ctrl-C press - it'd be disruptive to mappers. hostClipboard.SetText(getTimestamp()); if (CanCopy.Value) diff --git a/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs index 1aeb1d8a40fd..ff69689c914e 100644 --- a/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs +++ b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs @@ -2,16 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Game.Beatmaps; +using osu.Game.Localisation; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Edit { public partial class DeleteDifficultyConfirmationDialog : DeletionDialog { - public DeleteDifficultyConfirmationDialog(BeatmapInfo beatmapInfo, Action deleteAction) + public DeleteDifficultyConfirmationDialog(string difficultyName, int objectCount, Action deleteAction) { - BodyText = $"\"{beatmapInfo.DifficultyName}\" difficulty"; + BodyText = EditorDialogsStrings.DeleteDifficultyDetails(difficultyName, objectCount); DangerousAction = deleteAction; } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 05f74c851461..164d02d869d9 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -188,15 +188,14 @@ public bool ReadyForUse private bool isNewBeatmap; - protected override UserActivity InitialActivity + protected override UserActivity InitialActivity => getCurrentUserActivity(); + + private UserActivity getCurrentUserActivity() { - get - { - if (Beatmap.Value.Metadata.Author.OnlineID == api.LocalUser.Value.OnlineID) - return new UserActivity.EditingBeatmap(Beatmap.Value.BeatmapInfo); + if (Beatmap.Value.Metadata.Author.OnlineID == api.LocalUser.Value.OnlineID) + return new UserActivity.EditingBeatmap(Beatmap.Value.BeatmapInfo); - return new UserActivity.ModdingBeatmap(Beatmap.Value.BeatmapInfo); - } + return new UserActivity.ModdingBeatmap(Beatmap.Value.BeatmapInfo); } protected override bool InitialBackButtonVisibility => false; @@ -424,7 +423,7 @@ private void load(OsuConfigManager config) }, new OsuMenuItemSpacer(), new BackgroundDimMenuItem(editorBackgroundDim), - new ToggleMenuItem("Show storyboard") + new ToggleMenuItem(EditorStrings.ShowStoryboard) { State = { BindTarget = editorShowStoryboard }, }, @@ -480,7 +479,7 @@ private void load(OsuConfigManager config) [Resolved] private MusicController musicController { get; set; } - protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(); + protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(editorBeatmap); protected override void LoadComplete() { @@ -604,6 +603,9 @@ internal bool Save() updateLastSavedHash(); onScreenDisplay?.Display(new BeatmapEditorToast(ToastStrings.BeatmapSaved, editorBeatmap.BeatmapInfo.GetDisplayTitle())); Saved?.Invoke(); + + // This triggers an update to the window title post-save (ie if the difficulty name changed). + Activity.Value = getCurrentUserActivity(); return true; } @@ -1287,12 +1289,9 @@ private IEnumerable createFileMenuItems() Hotkey = new Hotkey(GlobalAction.EditorDiscardUnsavedChanges) }; - if (RuntimeInfo.OS != RuntimeInfo.Platform.Android) - { - var export = createExportMenu(); - saveRelatedMenuItems.AddRange(export.Items); - yield return export; - } + var export = createExportMenu(); + saveRelatedMenuItems.AddRange(export.Items); + yield return export; if (RuntimeInfo.IsDesktop) { @@ -1329,8 +1328,9 @@ private EditorMenuItem createExportMenu() { var exportItems = new List { - new EditorMenuItem(EditorStrings.ExportForEditing, MenuItemType.Standard, () => exportBeatmap(false)), - new EditorMenuItem(EditorStrings.ExportForCompatibility, MenuItemType.Standard, () => exportBeatmap(true)), + new EditorMenuItem(EditorStrings.ExportForEditing, MenuItemType.Standard, () => runExport(manager => manager.Export(Beatmap.Value.BeatmapSetInfo))), + new EditorMenuItem(EditorStrings.ExportForCompatibility, MenuItemType.Standard, () => runExport(manager => manager.ExportLegacy(Beatmap.Value.BeatmapSetInfo))), + new EditorMenuItem(EditorStrings.ExportGuestDifficulty, MenuItemType.Standard, () => runExport(manager => manager.ExportLegacy(Beatmap.Value.BeatmapInfo))), }; return new EditorMenuItem(CommonStrings.Export) { Items = exportItems }; @@ -1396,7 +1396,7 @@ private void submitBeatmap() void startSubmission() => this.Push(new BeatmapSubmissionScreen()); } - private void exportBeatmap(bool legacy) + private void runExport(Func exportAction) { if (HasUnsavedChanges) { @@ -1405,20 +1405,12 @@ private void exportBeatmap(bool legacy) if (!Save()) return Task.CompletedTask; - return runExport(); + return exportAction.Invoke(beatmapManager); }))); } else { - attemptAsyncMutationOperation(runExport); - } - - Task runExport() - { - if (legacy) - return beatmapManager.ExportLegacy(Beatmap.Value.BeatmapSetInfo); - else - return beatmapManager.Export(Beatmap.Value.BeatmapSetInfo); + attemptAsyncMutationOperation(() => exportAction(beatmapManager)); } } @@ -1435,7 +1427,7 @@ private void deleteDifficulty() if (dialogOverlay == null) delete(); else - dialogOverlay.Push(new DeleteDifficultyConfirmationDialog(Beatmap.Value.BeatmapInfo, delete)); + dialogOverlay.Push(new DeleteDifficultyConfirmationDialog(playableBeatmap.BeatmapInfo.DifficultyName, editorBeatmap.HitObjects.Count, delete)); void delete() { @@ -1627,8 +1619,9 @@ public bool HandleTimestamp(string timestamp, bool notifyOnError = false) private partial class BeatmapEditorToast : Toast { public BeatmapEditorToast(LocalisableString value, string beatmapDisplayName) - : base(InputSettingsStrings.EditorSection, value, beatmapDisplayName) + : base(InputSettingsStrings.EditorSection, value) { + ExtraText = beatmapDisplayName; } } diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 91ae4593dd61..48b793d1b5d8 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -101,11 +101,8 @@ public EditorBeatmap(IBeatmap playableBeatmap, ISkin beatmapSkin = null, Beatmap this.beatmapInfo = beatmapInfo ?? playableBeatmap.BeatmapInfo; - if (beatmapSkin is Skin skin) - { - BeatmapSkin = new EditorBeatmapSkin(skin); - BeatmapSkin.BeatmapSkinChanged += SaveState; - } + if (beatmapSkin is LegacyBeatmapSkin skin) + BeatmapSkin = new EditorBeatmapSkin(this, skin); beatmapProcessor = new EditorBeatmapProcessor(this, playableBeatmap.BeatmapInfo.Ruleset.CreateInstance()); @@ -532,5 +529,11 @@ private int findInsertionIndex(IReadOnlyList list, double startTime) public double GetBeatLengthAtTime(double referenceTime) => ControlPointInfo.TimingPointAt(referenceTime).BeatLength / BeatDivisor; public int BeatDivisor => beatDivisor?.Value ?? 1; + + protected override void Dispose(bool isDisposing) + { + BeatmapSkin?.Dispose(); + base.Dispose(isDisposing); + } } } diff --git a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs index 07fa1cb49cd8..a88330f77294 100644 --- a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs +++ b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,14 +18,20 @@ namespace osu.Game.Screens.Edit /// /// A beatmap skin which is being edited. /// - public class EditorBeatmapSkin : ISkin + public class EditorBeatmapSkin : ISkin, IDisposable { + /// + /// Invoked when the beatmap skin changes. + /// This event is not locally scheduled to update thread or otherwise marshalled + /// in a way that would prevent invocation of a callback registered by a potentially-now-disposed caller. + /// Callers are expected to schedule locally as required. + /// public event Action? BeatmapSkinChanged; /// /// The underlying beatmap skin. /// - protected internal readonly Skin Skin; + protected internal readonly LegacyBeatmapSkin Skin; /// /// The combo colours of this skin. @@ -31,10 +39,13 @@ public class EditorBeatmapSkin : ISkin /// public BindableList ComboColours { get; } - public EditorBeatmapSkin(Skin skin) + private readonly EditorBeatmap editorBeatmap; + + public EditorBeatmapSkin(EditorBeatmap editorBeatmap, LegacyBeatmapSkin skin) { - Skin = skin; + this.editorBeatmap = editorBeatmap; + Skin = skin; ComboColours = new BindableList(); if (Skin.Configuration.ComboColours is IReadOnlyList comboColours) @@ -48,9 +59,20 @@ public EditorBeatmapSkin(Skin skin) } ComboColours.BindCollectionChanged((_, _) => updateColours()); + + if (skin.BeatmapSetResources != null) + skin.BeatmapSetResources.CacheInvalidated += beatmapResourcesInvalidated; } - private void invokeSkinChanged() => BeatmapSkinChanged?.Invoke(); + private void beatmapResourcesInvalidated() + { + Skin.RecycleSamples(); + InvokeSkinChanged(); + } + + public void InvokeSkinChanged() => BeatmapSkinChanged?.Invoke(); + + #region Combo colours private void updateColours() { @@ -58,7 +80,83 @@ private void updateColours() Skin.Configuration.CustomComboColours.Clear(); for (int i = 0; i < ComboColours.Count; ++i) Skin.Configuration.CustomComboColours.Add(ComboColours[(ComboColours.Count + i - 1) % ComboColours.Count]); - invokeSkinChanged(); + InvokeSkinChanged(); + editorBeatmap.SaveState(); + } + + #endregion + + #region Sample sets + + public record SampleSet(int SampleSetIndex, string Name) + { + public SampleSet(int sampleSetIndex) + : this(sampleSetIndex, $@"Custom #{sampleSetIndex}") + { + } + + public override string ToString() => Name; + + public HashSet Filenames = []; + + public string? FindSampleIfExists(string sampleName, string bankName) + => Filenames.SingleOrDefault(f => f.StartsWith($@"{bankName}-{sampleName}{(SampleSetIndex > 1 ? SampleSetIndex : null)}", StringComparison.Ordinal)); + + public virtual bool Equals(SampleSet? other) => SampleSetIndex == other?.SampleSetIndex; + public override int GetHashCode() => SampleSetIndex; + } + + public IEnumerable GetAvailableSampleSets() + { + string[] possibleSounds = HitSampleInfo.ALL_ADDITIONS.Prepend(HitSampleInfo.HIT_NORMAL).ToArray(); + string[] possibleBanks = HitSampleInfo.ALL_BANKS; + + string[] possiblePrefixes = possibleSounds.SelectMany(sound => possibleBanks.Select(bank => $@"{bank}-{sound}")).ToArray(); + + Dictionary sampleSets = new Dictionary + { + [1] = new SampleSet(1), + }; + + if (Skin.Samples != null) + { + foreach (string sample in Skin.Samples.GetAvailableResources()) + { + foreach (string possiblePrefix in possiblePrefixes) + { + if (!sample.StartsWith(possiblePrefix, StringComparison.Ordinal)) + continue; + + string indexString = Path.GetFileNameWithoutExtension(sample)[possiblePrefix.Length..]; + int? index = null; + + if (string.IsNullOrEmpty(indexString)) + index = 1; + if (int.TryParse(indexString, out int parsed) && parsed >= 2) + index = parsed; + + if (!index.HasValue) + continue; + + SampleSet? sampleSet; + if (!sampleSets.TryGetValue(index.Value, out sampleSet)) + sampleSet = sampleSets[index.Value] = new SampleSet(index.Value); + + sampleSet.Filenames.Add(sample); + } + } + } + + return sampleSets.OrderBy(i => i.Key).Select(i => i.Value); + } + + #endregion + + public void Dispose() + { + if (Skin.BeatmapSetResources != null) + Skin.BeatmapSetResources.CacheInvalidated -= beatmapResourcesInvalidated; + Skin.Dispose(); } #region Delegated ISkin implementation diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index 8b9bdb595d78..d82ecbeff609 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -96,7 +96,7 @@ public bool SeekSnapped(double position) /// /// Whether to snap to the closest beat after seeking. /// The relative amount (magnitude) which should be seeked. - public void SeekBackward(bool snapped = false, double amount = 1) => seek(-1, snapped, amount + (IsRunning ? 1.5 : 0)); + public void SeekBackward(bool snapped = false, double amount = 1) => seek(-1, snapped, amount); /// /// Seeks forwards by one beat length. @@ -113,6 +113,16 @@ private void seek(int direction, bool snapped, double amount = 1) var timingPoint = ControlPointInfo.TimingPointAt(current); + if (IsRunning) + { + // when track is playing, seek a bit more than usual. + // the amount adjusted matches stable, because don't-break-what-works. + // https://github.com/peppy/osu-stable-reference/blob/7519cafd1823f1879c0d9c991ba0e5c7fd3bfa02/osu!/GameModes/Edit/Editor.cs#L1639-L1640 + // + // ReSharper disable once PossibleLossOfFraction + amount *= 1 + 250 / (int)timingPoint.BeatLength; + } + if (direction < 0 && timingPoint.Time == current) // When going backwards and we're at the boundary of two timing points, we compute the seek distance with the timing point which we are seeking into timingPoint = ControlPointInfo.TimingPointAt(current - 1); @@ -199,7 +209,7 @@ public bool Seek(double position) } /// - /// Seek smoothly to the provided destination. + /// Seek smoothly to the provided destination, if within a certain proximity to the current viewport. /// Use to perform an immediate seek. /// /// @@ -207,12 +217,17 @@ public void SeekSmoothlyTo(double seekDestination) { seekingOrStopped.Value = true; - if (IsRunning) - Seek(seekDestination); - else + // The whole point of seeking smoothly is to maintain continuity for the user. + // Above a certain proximity, there's little reason to do this as the jump is already huge. + const double smooth_seek_max_proximity = 5000; + + if (IsRunning || Math.Abs(seekDestination - currentTime) > smooth_seek_max_proximity) { - transformSeekTo(seekDestination, transform_time, Easing.OutQuint); + Seek(seekDestination); + return; } + + transformSeekTo(seekDestination, transform_time, Easing.OutQuint); } public void BindAdjustments() => track.Value?.BindAdjustments(AudioAdjustments); diff --git a/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs b/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs index 814b5dc18edf..c293b1ccd542 100644 --- a/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs +++ b/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs @@ -24,7 +24,7 @@ protected override void LoadComplete() base.LoadComplete(); if (beatmapSkin != null) - beatmapSkin.BeatmapSkinChanged += TriggerSourceChanged; + beatmapSkin.BeatmapSkinChanged += triggerSourceChanged; } protected override void Dispose(bool isDisposing) @@ -32,7 +32,9 @@ protected override void Dispose(bool isDisposing) base.Dispose(isDisposing); if (beatmapSkin != null) - beatmapSkin.BeatmapSkinChanged -= TriggerSourceChanged; + beatmapSkin.BeatmapSkinChanged -= triggerSourceChanged; } + + private void triggerSourceChanged() => Schedule(TriggerSourceChanged); } } diff --git a/osu.Game/Screens/Edit/ExternalEditScreen.cs b/osu.Game/Screens/Edit/ExternalEditScreen.cs index e906d748553f..21eff365583f 100644 --- a/osu.Game/Screens/Edit/ExternalEditScreen.cs +++ b/osu.Game/Screens/Edit/ExternalEditScreen.cs @@ -48,7 +48,10 @@ internal partial class ExternalEditScreen : OsuScreen private Task? fileMountOperation; - public ExternalEditOperation? EditOperation; + public ExternalEditOperation? EditOperation { get; private set; } + + private bool operationFinishStarted; + private bool operationFinished; private FillFlowContainer flow = null!; @@ -98,9 +101,13 @@ public override bool OnExiting(ScreenExitEvent e) if (fileMountOperation?.IsCompleted == false) return true; + // Similarly do not allow interrupting an ongoing finish. + if (operationFinishStarted && !operationFinished) + return true; + // If the operation completed successfully, ensure that we finish the operation before exiting. // The finish() call will subsequently call Exit() when done. - if (EditOperation != null) + if (EditOperation != null && !operationFinishStarted) { finish().FireAndForget(); return true; @@ -185,6 +192,12 @@ private void openDirectory() private async Task finish() { + if (operationFinishStarted) + return; + + operationFinishStarted = true; + + BackButtonVisibility.Value = false; string originalDifficulty = editor.Beatmap.Value.Beatmap.BeatmapInfo.DifficultyName; showSpinner("Cleaning up..."); @@ -206,7 +219,11 @@ private async Task finish() EditOperation = null; if (beatmap == null) + { + // has to be set before `Exit()` call to ensure the exit isn't blocked in `OnExiting()` + operationFinished = true; this.Exit(); + } else { // the `ImportAsUpdate()` flow will yield beatmap(sets) with online status of `None` if online lookup fails. @@ -223,6 +240,8 @@ private async Task finish() beatmap.Value.Beatmaps.FirstOrDefault(b => b.DifficultyName == originalDifficulty) ?? beatmap.Value.Beatmaps.First(); + // has to be set before `SwitchToDifficulty()` call to ensure the exit isn't blocked in `OnExiting()` + operationFinished = true; editor.SwitchToDifficulty(closestMatchingBeatmap); } } diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index eedde8b7a435..32762135e535 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Screens; @@ -17,8 +18,8 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Screens.Edit.GameplayTest @@ -180,10 +181,16 @@ public bool OnPressed(KeyBindingPressEvent e) switch (e.Action) { case GlobalAction.EditorTestPlayToggleAutoplay: + if (PauseOverlay?.State.Value == Visibility.Visible || DrawableRuleset.ResumeOverlay?.State.Value == Visibility.Visible) + return true; + toggleAutoplay(); return true; case GlobalAction.EditorTestPlayToggleQuickPause: + if (PauseOverlay?.State.Value == Visibility.Visible || DrawableRuleset.ResumeOverlay?.State.Value == Visibility.Visible) + return true; + toggleQuickPause(); return true; diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index e84b6bfc728e..06f2279ce0c8 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -179,22 +179,25 @@ private void findChangedIndices(DiffResult result, LegacyDecoder.Sectio removedIndices = new List(); addedIndices = new List(); + string[] oldArr = result.PiecesOld.ToArray(); + string[] newArr = result.PiecesNew.ToArray(); + // Find the start and end indices of the relevant section headers in both the old and the new beatmap file. Lines changed outside of the modified ranges are ignored. - int oldSectionStartIndex = Array.IndexOf(result.PiecesOld, $"[{section}]"); + int oldSectionStartIndex = Array.IndexOf(oldArr, $"[{section}]"); if (oldSectionStartIndex == -1) return; - int oldSectionEndIndex = Array.FindIndex(result.PiecesOld, oldSectionStartIndex + 1, s => s.StartsWith('[')); + int oldSectionEndIndex = Array.FindIndex(oldArr, oldSectionStartIndex + 1, s => s.StartsWith('[')); if (oldSectionEndIndex == -1) - oldSectionEndIndex = result.PiecesOld.Length; + oldSectionEndIndex = oldArr.Length; - int newSectionStartIndex = Array.IndexOf(result.PiecesNew, $"[{section}]"); + int newSectionStartIndex = Array.IndexOf(newArr, $"[{section}]"); if (newSectionStartIndex == -1) return; - int newSectionEndIndex = Array.FindIndex(result.PiecesNew, newSectionStartIndex + 1, s => s.StartsWith('[')); + int newSectionEndIndex = Array.FindIndex(newArr, newSectionStartIndex + 1, s => s.StartsWith('[')); if (newSectionEndIndex == -1) - newSectionEndIndex = result.PiecesNew.Length; + newSectionEndIndex = newArr.Length; foreach (var block in result.DiffBlocks) { diff --git a/osu.Game/Screens/Edit/SaveRequiredPopupDialog.cs b/osu.Game/Screens/Edit/SaveRequiredPopupDialog.cs index 618efb7cdaf4..5c690378256d 100644 --- a/osu.Game/Screens/Edit/SaveRequiredPopupDialog.cs +++ b/osu.Game/Screens/Edit/SaveRequiredPopupDialog.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Game.Localisation; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Edit @@ -11,7 +12,7 @@ public partial class SaveRequiredPopupDialog : PopupDialog { public SaveRequiredPopupDialog(Action saveAndAction) { - HeaderText = "The beatmap will be saved to continue with this operation."; + HeaderText = EditorDialogsStrings.SaveRequiredDialogHeader; Icon = FontAwesome.Regular.Save; @@ -19,12 +20,12 @@ public SaveRequiredPopupDialog(Action saveAndAction) { new PopupDialogOkButton { - Text = "Sounds good, let's go!", + Text = EditorDialogsStrings.SoundsGood, Action = saveAndAction }, new PopupDialogCancelButton { - Text = "Oops, continue editing", + Text = EditorDialogsStrings.ContinueEditing, }, }; } diff --git a/osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs b/osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs new file mode 100644 index 000000000000..f6139da317bc --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs @@ -0,0 +1,148 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Edit.Setup +{ + public partial class FormSampleSetChooser : FormDropdown, IHasPopover + { + private EditorBeatmapSkin? beatmapSkin; + + public FormSampleSetChooser() + { + Caption = EditorSetupStrings.CustomSampleSets; + } + + [BackgroundDependencyLoader] + private void load(EditorBeatmap editorBeatmap) + { + beatmapSkin = editorBeatmap.BeatmapSkin; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + populateItems(); + if (beatmapSkin != null) + beatmapSkin.BeatmapSkinChanged += scheduleItemPopulation; + + Current.Value = Items.First(i => i?.SampleSetIndex > 0); + Current.BindValueChanged(val => + { + if (val.NewValue?.SampleSetIndex == -1) + this.ShowPopover(); + }); + } + + private void populateItems() + { + var items = beatmapSkin?.GetAvailableSampleSets().ToList() ?? [new EditorBeatmapSkin.SampleSet(1)]; + items.Add(new EditorBeatmapSkin.SampleSet(-1, "Add new...")); + Items = items; + } + + private void scheduleItemPopulation() => Schedule(populateItems); + + protected override LocalisableString GenerateItemText(EditorBeatmapSkin.SampleSet? item) + { + if (item == null) + return string.Empty; + + return base.GenerateItemText(item); + } + + public Popover GetPopover() => new NewSampleSetPopover( + Items.Any(i => i?.SampleSetIndex > 0) ? Items.Max(i => i!.SampleSetIndex) : 0, + idx => + { + if (idx == null) + { + Current.Value = Items.FirstOrDefault(i => i?.SampleSetIndex > 0); + return; + } + + if (Items.SingleOrDefault(i => i?.SampleSetIndex == idx) is EditorBeatmapSkin.SampleSet existing) + { + Current.Value = existing; + return; + } + + var sampleSet = new EditorBeatmapSkin.SampleSet(idx.Value, $@"Custom #{idx}"); + var newItems = Items.ToList(); + newItems.Insert(newItems.Count - 1, sampleSet); + Items = newItems; + Current.Value = sampleSet; + }); + + protected override void Dispose(bool isDisposing) + { + if (beatmapSkin != null) + beatmapSkin.BeatmapSkinChanged -= scheduleItemPopulation; + + base.Dispose(isDisposing); + } + + private partial class NewSampleSetPopover : OsuPopover + { + private readonly int currentLargestIndex; + private readonly Action onCommit; + + private int? committedIndex; + + private LabelledNumberBox numberBox = null!; + + public NewSampleSetPopover(int currentLargestIndex, Action onCommit) + { + this.currentLargestIndex = currentLargestIndex; + this.onCommit = onCommit; + } + + [BackgroundDependencyLoader] + private void load() + { + Child = numberBox = new LabelledNumberBox + { + RelativeSizeAxes = Axes.None, + Width = 250, + Label = "Sample set index", + Current = { Value = (currentLargestIndex + 1).ToString(CultureInfo.InvariantCulture) } + }; + numberBox.OnCommit += (_, _) => + { + if (int.TryParse(numberBox.Current.Value, out int parsed)) + committedIndex = parsed; + Hide(); + }; + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + // avoids infinite refocus loop + if (committedIndex == null) + GetContainingFocusManager()?.ChangeFocus(numberBox); + } + + public override void Hide() + { + if (State.Value == Visibility.Visible) + onCommit.Invoke(committedIndex > 0 ? committedIndex : null); + base.Hide(); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index f52d865d5f62..8a55db8420cf 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -14,6 +14,7 @@ using osu.Game.Models; using osu.Game.Overlays; using osu.Game.Screens.Backgrounds; +using osu.Game.Screens.Edit.Components; using osu.Game.Utils; namespace osu.Game.Screens.Edit.Setup @@ -23,6 +24,8 @@ public partial class ResourcesSection : SetupSection private FormBeatmapFileSelector audioTrackChooser = null!; private FormBeatmapFileSelector backgroundChooser = null!; + private readonly Bindable currentSampleSet = new Bindable(); + public override LocalisableString Title => EditorSetupStrings.ResourcesHeader; [Resolved] @@ -65,6 +68,27 @@ private void load() Caption = EditorSetupStrings.AudioTrack, PlaceholderText = EditorSetupStrings.ClickToSelectTrack, }, + new FormSampleSetChooser + { + Current = { BindTarget = currentSampleSet }, + }, + new FormSampleSet + { + Current = { BindTarget = currentSampleSet }, + SampleAddRequested = (file, targetName) => + { + string actualFilename = string.Concat(targetName, file.Extension); + using var stream = file.OpenRead(); + beatmaps.AddFile(working.Value.BeatmapSetInfo, stream, actualFilename); + return actualFilename; + }, + SampleRemoveRequested = filename => + { + var file = working.Value.BeatmapSetInfo.GetFile(filename); + if (file != null) + beatmaps.DeleteFile(working.Value.BeatmapSetInfo, file); + } + }, }; backgroundChooser.PreviewContainer.Add(headerBackground); diff --git a/osu.Game/Screens/Edit/Timing/EffectSection.cs b/osu.Game/Screens/Edit/Timing/EffectSection.cs index f9ef46023266..325d0cfaf6e8 100644 --- a/osu.Game/Screens/Edit/Timing/EffectSection.cs +++ b/osu.Game/Screens/Edit/Timing/EffectSection.cs @@ -11,21 +11,24 @@ namespace osu.Game.Screens.Edit.Timing { internal partial class EffectSection : Section { - private LabelledSwitchButton kiai = null!; + private FormCheckBox kiai = null!; - private SliderWithTextBoxInput scrollSpeedSlider = null!; + private FormSliderBar scrollSpeedSlider { get; set; } = null!; [BackgroundDependencyLoader] private void load() { Flow.AddRange(new Drawable[] { - kiai = new LabelledSwitchButton { Label = "Kiai Time" }, - scrollSpeedSlider = new SliderWithTextBoxInput("Scroll Speed") + kiai = new FormCheckBox { Caption = "Kiai Time" }, + scrollSpeedSlider = new FormSliderBar { + Caption = "Scroll Speed", Current = new EffectControlPoint().ScrollSpeedBindable, - KeyboardStep = 0.1f - } + KeyboardStep = 0.1f, + TransferValueOnCommit = true, + TabbableContentContainer = this + }, }); } diff --git a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs index 00cf2e349357..0e7b4767a743 100644 --- a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Edit.Timing { /// - /// Analogous to , but supports scenarios + /// Analogous to SliderWithTextBoxInput, but supports scenarios /// where multiple objects with multiple different property values are selected /// by providing an "indeterminate state". /// diff --git a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs index f91a67a7e36c..50787726fb27 100644 --- a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs @@ -15,6 +15,7 @@ using osu.Framework.Threading; using osu.Framework.Timing; using osu.Framework.Utils; +using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -414,14 +415,7 @@ protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, if (!IsBeatSyncedWithTrack || !EnableClicking) return; - var channel = beatIndex % timingPoint.TimeSignature.Numerator == 0 ? sampleTickDownbeat?.GetChannel() : sampleTick?.GetChannel(); - - if (channel == null) - return; - - channel.Frequency.Value = RNG.NextDouble(0.98f, 1.02f); - channel.Play(); - + SamplePlaybackHelper.PlayWithRandomPitch(beatIndex % timingPoint.TimeSignature.Numerator == 0 ? sampleTickDownbeat : sampleTick, 0.02f); Ticked?.Invoke(); } } diff --git a/osu.Game/Screens/Footer/ScreenBackButton.cs b/osu.Game/Screens/Footer/ScreenBackButton.cs index 481192088c36..37d4260d1825 100644 --- a/osu.Game/Screens/Footer/ScreenBackButton.cs +++ b/osu.Game/Screens/Footer/ScreenBackButton.cs @@ -32,14 +32,11 @@ public sealed override bool ReceivePositionalInputAt(Vector2 screenSpacePos) return inputRectangle.Contains(ToLocalSpace(screenSpacePos)); } - public ScreenBackButton() - : base(BUTTON_WIDTH) - { - } - [BackgroundDependencyLoader] private void load() { + Width = BUTTON_WIDTH; + ButtonContent.Child = new FillFlowContainer { X = -10f, diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index 5dbc7a55ab84..8a08f786c7fa 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -291,7 +291,9 @@ private void clearActiveOverlayContainer() return; Debug.Assert(activeOverlayContent != null); + activeOverlayContent.Hide(); + activeOverlayContent.Expire(); double timeUntilRun = activeOverlayContent.LatestTransformEndTime - Time.Current; @@ -299,6 +301,7 @@ private void clearActiveOverlayContainer() { var button = temporarilyHiddenButtons[i]; hiddenButtonsContainer.Remove(button, false); + // temporarily bypass autosize on the X axis to prevent the buttons taking space // immediately upon being moved back to the flow. // this prevents the overlay content jumping to the right during its fade-out. @@ -312,12 +315,13 @@ private void clearActiveOverlayContainer() updateColourScheme(OverlayColourScheme.Aquamarine.GetHue()); - activeOverlayContent.Delay(timeUntilRun).Schedule(() => + Scheduler.AddDelayed(() => { // overlay content is done displaying, re-enable autosize on all active buttons foreach (var button in buttonsFlow) button.BypassAutoSizeAxes = Axes.None; - }).Expire(); + }, timeUntilRun); + activeOverlayContent = null; ActiveOverlay = null; } diff --git a/osu.Game/Screens/Footer/ScreenStackFooter.cs b/osu.Game/Screens/Footer/ScreenStackFooter.cs index 807dcc3fe0d3..d10a2d8fc48b 100644 --- a/osu.Game/Screens/Footer/ScreenStackFooter.cs +++ b/osu.Game/Screens/Footer/ScreenStackFooter.cs @@ -170,6 +170,7 @@ private class ScreenStackTracker : IDisposable /// /// The screen which should be bound to the screen footer - the most nested subscreen. /// + // ReSharper disable once FunctionRecursiveOnAllPaths (TODO: remove after fixed https://youtrack.jetbrains.com/issue/RIDER-135036/Incorrect-recursive-on-all-execution-paths-inspection) private IScreen leadingScreen => subScreenTracker?.leadingScreen ?? stack.CurrentScreen; public ScreenStackTracker(ScreenStack stack) diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index a73fafcffdc2..926e824d7f9d 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -47,7 +47,8 @@ public partial class ButtonSystem : Container, IStateful, IKe public Action? OnSolo; public Action? OnSettings; public Action? OnMultiplayer; - public Action? OnMatchmaking; + public Action? OnQuickPlay; + public Action? OnRankedPlay; public Action? OnPlaylists; public Action? OnDailyChallenge; @@ -157,11 +158,15 @@ private void load(AudioManager audio, IdleTracker? idleTracker, GameHost host) buttonsPlay.Add(new DailyChallengeButton(@"button-daily-select", new Color4(94, 63, 186, 255), onDailyChallenge, Key.D)); buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play); - buttonsMulti.Add(new MainMenuButton("lounge", @"button-default-select", FontAwesome.Solid.Couch, new Color4(94, 63, 186, 255), onMultiplayer, Key.L, Key.M) + buttonsMulti.Add(new MainMenuButton(ButtonSystemStrings.Lounge, @"button-default-select", FontAwesome.Solid.Couch, new Color4(94, 63, 186, 255), onMultiplayer, Key.L, Key.M) { Padding = new MarginPadding { Left = WEDGE_WIDTH } }); - buttonsMulti.Add(new MainMenuButton("quick play", @"button-daily-select", FontAwesome.Solid.Bolt, new Color4(94, 63, 186, 255), onMatchmaking, Key.Q)); +#if DEBUG + buttonsMulti.Add(new MainMenuButton(ButtonSystemStrings.RankedPlay, @"button-daily-select", FontAwesome.Solid.Crown, new Color4(94, 63, 186, 255), onRankedPlay, Key.R)); +#else + buttonsMulti.Add(new MainMenuButton(ButtonSystemStrings.QuickPlay, @"button-daily-select", FontAwesome.Solid.Bolt, new Color4(94, 63, 186, 255), onQuickPlay, Key.Q)); +#endif buttonsMulti.ForEach(b => b.VisibleState = ButtonSystemState.Multi); buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B, @@ -217,7 +222,7 @@ private void onMultiplayer(MainMenuButton mainMenuButton, UIEvent uiEvent) OnMultiplayer?.Invoke(); } - private void onMatchmaking(MainMenuButton mainMenuButton, UIEvent uiEvent) + private void onQuickPlay(MainMenuButton mainMenuButton, UIEvent uiEvent) { if (api.State.Value != APIState.Online) { @@ -225,7 +230,18 @@ private void onMatchmaking(MainMenuButton mainMenuButton, UIEvent uiEvent) return; } - OnMatchmaking?.Invoke(); + OnQuickPlay?.Invoke(); + } + + private void onRankedPlay(MainMenuButton mainMenuButton, UIEvent uiEvent) + { + if (api.State.Value != APIState.Online) + { + loginOverlay?.Show(); + return; + } + + OnRankedPlay?.Invoke(); } private void onPlaylists(MainMenuButton mainMenuButton, UIEvent uiEvent) @@ -286,6 +302,9 @@ protected override bool OnKeyDown(KeyDownEvent e) if (e.Key >= Key.F1 && e.Key <= Key.F35) return false; + if (e.Key >= Key.Mute && e.Key <= Key.TrackNext) + return false; + switch (e.Key) { case Key.Escape: diff --git a/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs b/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs index 0cd3e9ce71c8..b57d786f5687 100644 --- a/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs +++ b/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Game.Localisation; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Menu @@ -16,8 +17,8 @@ public partial class ConfirmDiscardChangesDialog : PopupDialog /// An optional action to perform on cancel. public ConfirmDiscardChangesDialog(Action onConfirm, Action? onCancel = null) { - HeaderText = "Are you sure you want to go back?"; - BodyText = "This will discard any unsaved changes"; + HeaderText = DialogStrings.ConfirmDiscardChangesHeaderText; + BodyText = DialogStrings.ConfirmDiscardChangesBodyText; Icon = FontAwesome.Solid.ExclamationTriangle; @@ -25,12 +26,12 @@ public ConfirmDiscardChangesDialog(Action onConfirm, Action? onCancel = null) { new PopupDialogDangerousButton { - Text = @"Yes", + Text = DialogStrings.Confirm, Action = onConfirm }, new PopupDialogCancelButton { - Text = @"No I didn't mean to", + Text = DialogStrings.ConfirmDiscardChangesCancelButton, Action = onCancel }, }; diff --git a/osu.Game/Screens/Menu/ConfirmExitDialog.cs b/osu.Game/Screens/Menu/ConfirmExitDialog.cs index e33071e78c0f..3e085ce063b7 100644 --- a/osu.Game/Screens/Menu/ConfirmExitDialog.cs +++ b/osu.Game/Screens/Menu/ConfirmExitDialog.cs @@ -5,6 +5,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; @@ -30,31 +31,29 @@ public ConfirmExitDialog(Action onConfirm, Action? onCancel = null) [BackgroundDependencyLoader] private void load(INotificationOverlay notifications) { - HeaderText = "Are you sure you want to exit osu!?"; + HeaderText = DialogStrings.ConfirmExitHeaderText; Icon = FontAwesome.Solid.ExclamationTriangle; if (notifications.HasOngoingOperations) { - string text = "There are currently some background operations which will be aborted if you continue:\n\n"; - var ongoingOperations = notifications.OngoingOperations.ToArray(); + string ongoingOperationsText = ongoingOperations.Take(10).Aggregate(string.Empty, (current, n) => current + $"{n.Text} ({n.Progress:0%})\n"); - foreach (var n in ongoingOperations.Take(10)) - text += $"{n.Text} ({n.Progress:0%})\n"; + LocalisableString ongoingOperationsLocalisableString; if (ongoingOperations.Length > 10) - text += $"\nand {ongoingOperations.Length - 10} other operation(s).\n"; - - text += "\nLast chance to turn back"; + ongoingOperationsLocalisableString = DialogStrings.ConfirmExitBodyTextOtherOngoingOperations(ongoingOperationsText, ongoingOperations.Length - 10); + else + ongoingOperationsLocalisableString = DialogStrings.ConfirmExitBodyTextOngoingOperations(ongoingOperationsText); - BodyText = text; + BodyText = LocalisableString.Interpolate($"{ongoingOperationsLocalisableString}\n\n{DialogStrings.ConfirmDialogBodyText}"); Buttons = new PopupDialogButton[] { new PopupDialogDangerousButton { - Text = @"Let me out!", + Text = DialogStrings.ConfirmExitOkButton, Action = onConfirm }, new PopupDialogCancelButton @@ -66,18 +65,18 @@ private void load(INotificationOverlay notifications) } else { - BodyText = "Last chance to turn back"; + BodyText = DialogStrings.ConfirmDialogBodyText; Buttons = new PopupDialogButton[] { new PopupDialogOkButton { - Text = @"Let me out!", + Text = DialogStrings.ConfirmExitOkButton, Action = onConfirm }, new PopupDialogCancelButton { - Text = @"Just a little more...", + Text = DialogStrings.ConfirmExitCancelButton, Action = onCancel }, }; diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs index aab3afcd248f..9b8252a4b743 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; -using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Timing; using osu.Framework.Utils; @@ -342,9 +341,9 @@ private void load(RulesetStore rulesets) Add(icon); } - catch + catch (Exception e) { - Logger.Log($"Could not create ruleset icon for {ruleset.Name}. Please check for an update from the developer.", level: LogLevel.Error); + RulesetStore.LogRulesetFailure(ruleset, e); } } } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 2296213dd657..6e7e8e7a743b 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -29,6 +29,7 @@ using osu.Game.IO; using osu.Game.Localisation; using osu.Game.Online.API; +using osu.Game.Online.Matchmaking; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.SkinEditor; @@ -39,7 +40,7 @@ using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Playlists; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osu.Game.Seasonal; using osuTK; using osuTK.Graphics; @@ -159,7 +160,8 @@ private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings }, OnSolo = loadSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), - OnMatchmaking = joinOrLeaveMatchmakingQueue, + OnQuickPlay = loadQuickPlay, + OnRankedPlay = loadRankedPlay, OnPlaylists = () => this.Push(new Playlists()), OnDailyChallenge = room => { @@ -482,7 +484,9 @@ public void OnReleased(KeyBindingReleaseEvent e) private void loadSongSelect() => this.Push(new SoloSongSelect()); - private void joinOrLeaveMatchmakingQueue() => this.Push(new OnlinePlay.Matchmaking.Intro.ScreenIntro()); + private void loadQuickPlay() => this.Push(new OnlinePlay.Matchmaking.Intro.ScreenIntro(MatchmakingPoolType.QuickPlay)); + + private void loadRankedPlay() => this.Push(new OnlinePlay.Matchmaking.Intro.ScreenIntro(MatchmakingPoolType.RankedPlay)); private partial class MobileDisclaimerDialog : PopupDialog { @@ -497,7 +501,7 @@ public MobileDisclaimerDialog(Action confirmed) { new PopupDialogOkButton { - Text = "Understood", + Text = ButtonSystemStrings.MobileDisclaimerOkButton, Action = confirmed, }, }; diff --git a/osu.Game/Screens/Menu/MainMenuButton.cs b/osu.Game/Screens/Menu/MainMenuButton.cs index 235babeed21b..f8824795d8b0 100644 --- a/osu.Game/Screens/Menu/MainMenuButton.cs +++ b/osu.Game/Screens/Menu/MainMenuButton.cs @@ -20,7 +20,6 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; -using osu.Framework.Input.StateChanges; using osu.Framework.Localisation; using osu.Game.Beatmaps.ControlPoints; @@ -258,15 +257,6 @@ protected override bool OnMouseDown(MouseDownEvent e) protected override void OnMouseUp(MouseUpEvent e) { - // HORRIBLE HACK - // This is here so that on mobile, the main menu button that progresses to song select can correctly progress to song select v2 when held. - // Once the temporary solution of holding the button to access song select v2 is removed, this should be too. - // Without this, the long-press-to-right-click flow intercepts the hold and converts it to a right click which would not trigger the button - // and therefore not progress to song select. - if (e.Button == MouseButton.Right && e.CurrentState.Mouse.LastSource is ISourcedFromTouch) - trigger(e); - // END OF HORRIBLE HACK - boxHoverLayer.FadeTo(0, 1000, Easing.OutQuint); base.OnMouseUp(e); } diff --git a/osu.Game/Screens/Menu/MenuTipDisplay.cs b/osu.Game/Screens/Menu/MenuTipDisplay.cs index d9c90b069d83..b80c7c3b4ee4 100644 --- a/osu.Game/Screens/Menu/MenuTipDisplay.cs +++ b/osu.Game/Screens/Menu/MenuTipDisplay.cs @@ -199,7 +199,11 @@ private LocalisableString getRandomTip() return MenuTipStrings.ModCustomisationSettings; case 23: - return MenuTipStrings.RandomSkinShortcut(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.RandomSkin).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding); + return MenuTipStrings.SkinChangeShortcuts([ + keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.RandomSkin).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding, + keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.PreviousSkin).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding, + keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.NextSkin).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding, + ]); case 24: return MenuTipStrings.ToggleReplaySettingsShortcut(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.ToggleReplaySettings).FirstOrDefault() diff --git a/osu.Game/Screens/Menu/OnlineMenuBanner.cs b/osu.Game/Screens/Menu/OnlineMenuBanner.cs index aa73ce213663..c94a43851245 100644 --- a/osu.Game/Screens/Menu/OnlineMenuBanner.cs +++ b/osu.Game/Screens/Menu/OnlineMenuBanner.cs @@ -70,7 +70,7 @@ private void checkForUpdates() return; var request = new GetMenuContentRequest(); - Task.Run(() => request.Perform()) + Task.Run(request.Perform) .ContinueWith(r => { if (!FetchOnlineContent) diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 1b3317b12d81..ecd52f95b473 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -17,7 +17,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; -using osu.Framework.Input.StateChanges; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Backgrounds; @@ -393,27 +392,12 @@ protected override bool OnMouseDown(MouseDownEvent e) protected override void OnMouseUp(MouseUpEvent e) { - // HORRIBLE HACK - // This is here so that on mobile, the logo can correctly progress from main menu to song select v2 when held. - // Once the temporary solution of holding the logo to access song select v2 is removed, this should be too. - // Without this, the long-press-to-right-click flow intercepts the hold and converts it to a right click which would not trigger the logo - // and therefore not progress to song select. - if (e.Button == MouseButton.Right && e.CurrentState.Mouse.LastSource is ISourcedFromTouch) - triggerClick(); - // END OF HORRIBLE HACK - if (e.Button != MouseButton.Left) return; logoBounceContainer.ScaleTo(1f, 500, Easing.OutElastic); } protected override bool OnClick(ClickEvent e) - { - triggerClick(); - return true; - } - - private void triggerClick() { flashLayer.ClearTransforms(); flashLayer.Alpha = 0.4f; @@ -425,6 +409,8 @@ private void triggerClick() sampleClickChannel = sampleClick.GetChannel(); sampleClickChannel.Play(); } + + return true; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Screens/OnlinePlay/Components/BeatmapDetailAreaPlaylistTabItem.cs b/osu.Game/Screens/OnlinePlay/Components/BeatmapDetailAreaPlaylistTabItem.cs deleted file mode 100644 index 41b994ea32ad..000000000000 --- a/osu.Game/Screens/OnlinePlay/Components/BeatmapDetailAreaPlaylistTabItem.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Screens.Select; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public class BeatmapDetailAreaPlaylistTabItem : BeatmapDetailAreaTabItem - { - public override string Name => "Playlist"; - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/ConfirmExitMultiplayerMatchDialog.cs b/osu.Game/Screens/OnlinePlay/Components/ConfirmExitMultiplayerMatchDialog.cs new file mode 100644 index 000000000000..ad1e053726ff --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Components/ConfirmExitMultiplayerMatchDialog.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 System; +using osu.Game.Localisation; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.OnlinePlay.Components +{ + public partial class ConfirmExitMultiplayerMatchDialog : ConfirmDialog + { + public ConfirmExitMultiplayerMatchDialog(Action onConfirm) + : base(DialogStrings.ConfirmExitMultiplayerMatchBodyText, onConfirm) + { + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs b/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs deleted file mode 100644 index 1f2b2e3fc2c7..000000000000 --- a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.ComponentModel; -using System.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Playlists; -using osu.Game.Screens.Select; -using osuTK; -using Container = osu.Framework.Graphics.Containers.Container; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public partial class MatchBeatmapDetailArea : BeatmapDetailArea - { - public Action? CreateNewItem; - - private readonly Room room; - private readonly GridContainer playlistArea; - private readonly DrawableRoomPlaylist playlist; - - public MatchBeatmapDetailArea(Room room) - { - this.room = room; - - Add(playlistArea = new GridContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Vertical = 10 }, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = 10 }, - Child = playlist = new PlaylistsRoomSettingsPlaylist - { - RelativeSizeAxes = Axes.Both - } - } - }, - new Drawable[] - { - new RoundedButton - { - Text = "Add new playlist entry", - RelativeSizeAxes = Axes.Both, - Size = Vector2.One, - Action = () => CreateNewItem?.Invoke() - } - }, - }, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 50), - } - }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - playlist.Items.BindCollectionChanged((_, __) => room.Playlist = playlist.Items.ToArray()); - - room.PropertyChanged += onRoomPropertyChanged; - updateRoomPlaylist(); - } - - private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(Room.Playlist)) - updateRoomPlaylist(); - } - - private void updateRoomPlaylist() - => playlist.Items.ReplaceRange(0, playlist.Items.Count, room.Playlist); - - protected override void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) - { - base.OnTabChanged(tab, selectedMods); - - switch (tab) - { - case BeatmapDetailAreaPlaylistTabItem: - playlistArea.Show(); - break; - - default: - playlistArea.Hide(); - break; - } - } - - protected override BeatmapDetailAreaTabItem[] CreateTabItems() => base.CreateTabItems().Prepend(new BeatmapDetailAreaPlaylistTabItem()).ToArray(); - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - room.PropertyChanged -= onRoomPropertyChanged; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs index 893bc4eb5c90..6db293ec7167 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs @@ -44,7 +44,6 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge { - [Cached(typeof(IPreviewTrackOwner))] public partial class DailyChallenge : OsuScreen, IPreviewTrackOwner, IHandlePresentBeatmap { private readonly Room room; @@ -490,7 +489,7 @@ public static void TrySetDailyChallengeBeatmap(OsuScreen screen, BeatmapManager if (!screen.IsCurrentScreen()) return; - var beatmap = beatmaps.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.Beatmap.OnlineID); + var beatmap = beatmaps.QueryOnlineBeatmapId(item.Beatmap.OnlineID); screen.Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap); // this will gracefully fall back to dummy beatmap if missing locally. screen.Ruleset.Value = rulesets.GetRuleset(item.RulesetID); diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs index 65805a970db8..62c5c0c8dfb5 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs @@ -17,7 +17,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osuTK; namespace osu.Game.Screens.OnlinePlay.DailyChallenge diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index 7c632d16194e..85299427ebc2 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -1,97 +1,113 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Select; using osuTK; namespace osu.Game.Screens.OnlinePlay { - public partial class FooterButtonFreeMods : FooterButton + public partial class FooterButtonFreeMods : ScreenFooterButton { - public readonly Bindable> FreeMods = new Bindable>(); - public readonly IBindable Freestyle = new Bindable(); - - protected override bool IsActive => FreeMods.Value.Count > 0; + public readonly Bindable> FreeMods = new Bindable>([]); + public readonly Bindable Freestyle = new Bindable(); public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); } - private OsuSpriteText count = null!; - private Circle circle = null!; + [Resolved] + private OsuColour colours { get; set; } = null!; - private readonly FreeModSelectOverlay freeModSelectOverlay; + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; - public FooterButtonFreeMods(FreeModSelectOverlay freeModSelectOverlay) - { - this.freeModSelectOverlay = freeModSelectOverlay; + private Container modsWedge = null!; + private ModDisplay modDisplay = null!; + private Container modContainer = null!; + private FooterButtonMods.ModCountText overflowModCountDisplay = null!; - // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. - base.Action = toggleAllFreeMods; + public FooterButtonFreeMods(ModSelectOverlay overlay) + : base(overlay) + { } - [Resolved] - private OsuColour colours { get; set; } = null!; - [BackgroundDependencyLoader] private void load() { - ButtonContentContainer.AddRange(new[] + Text = OnlinePlayStrings.FooterButtonFreemods; + Icon = FontAwesome.Solid.ExchangeAlt; + AccentColour = colours.Lime1; + + Add(modsWedge = new InputBlockingContainer { - new Container + Y = -5f, + Depth = float.MaxValue, + Origin = Anchor.BottomLeft, + Shear = OsuGame.SHEAR, + CornerRadius = CORNER_RADIUS, + Size = new Vector2(BUTTON_WIDTH, FooterButtonMods.BAR_HEIGHT), + Masking = true, + EdgeEffect = new EdgeEffectParameters { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] + Type = EdgeEffectType.Shadow, + Radius = 4, + // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. + Colour = Colour4.Black.Opacity(0.25f), + Offset = new Vector2(0, 2), + }, + Alpha = 0, + Children = new Drawable[] + { + new Box { - circle = new Circle - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = colours.YellowDark, - RelativeSizeAxes = Axes.Both, - }, - count = new OsuSpriteText + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + modContainer = new Container + { + CornerRadius = CORNER_RADIUS, + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Padding = new MarginPadding(5), - UseFullGlyphHeight = false, + modDisplay = new ModDisplay(showExtendedInformation: true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -OsuGame.SHEAR, + Scale = new Vector2(0.5f), + Current = { BindTarget = FreeMods }, + ExpansionMode = ExpansionMode.AlwaysContracted, + }, + overflowModCountDisplay = new FooterButtonMods.ModCountText + { + Mods = { BindTarget = FreeMods }, + }, } - } - }, - new IconButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(0.8f), - Icon = FontAwesome.Solid.Bars, - Enabled = { BindTarget = Enabled }, - Action = () => freeModSelectOverlay.ToggleVisibility() + }, } }); - SelectedColour = colours.Yellow; - DeselectedColour = SelectedColour.Opacity(0.5f); - Text = @"freemods"; - TooltipText = MultiplayerMatchStrings.FreeModsButtonTooltip; } @@ -99,48 +115,39 @@ protected override void LoadComplete() { base.LoadComplete(); - Freestyle.BindValueChanged(_ => updateModDisplay()); - FreeMods.BindValueChanged(_ => updateModDisplay(), true); + Freestyle.BindValueChanged(f => + { + Enabled.Value = !f.NewValue; + overflowModCountDisplay.CustomText = f.NewValue ? ModSelectOverlayStrings.AllMods.ToUpper() : (LocalisableString?)null; + }, true); + FreeMods.BindValueChanged(m => + { + if (m.NewValue.Count == 0 && !Freestyle.Value) + modsWedge.FadeOut(300, Easing.OutExpo); + else + modsWedge.FadeIn(300, Easing.OutExpo); + }, true); } - /// - /// Immediately toggle all free mods on/off. - /// - private void toggleAllFreeMods() + protected override void Update() { - var availableMods = allAvailableAndValidMods.ToArray(); + base.Update(); - FreeMods.Value = FreeMods.Value.Count == availableMods.Length - ? Array.Empty() - : availableMods; - } + // If there are freemods selected but the display has no width, it's still loading. + // Don't update visibility in this state or we will cause an awkward flash. + if (FreeMods.Value.Count > 0 && Precision.AlmostEquals(modDisplay.DrawWidth, 0)) + return; - private void updateModDisplay() - { - int currentCount = FreeMods.Value.Count; + bool showCountText = + // When freestyle is enabled this text shows "ALL MODS" + Freestyle.Value + // Standard flow where mods are overflowing so we show count text. + || modDisplay.DrawWidth * modDisplay.Scale.X > modContainer.DrawWidth; - if (currentCount == allAvailableAndValidMods.Count() || Freestyle.Value) - { - count.Text = "all"; - count.FadeColour(colours.Gray2, 200, Easing.OutQuint); - circle.FadeColour(colours.Yellow, 200, Easing.OutQuint); - } - else if (currentCount > 0) - { - count.Text = $"{currentCount} mods"; - count.FadeColour(colours.Gray2, 200, Easing.OutQuint); - circle.FadeColour(colours.YellowDark, 200, Easing.OutQuint); - } + if (showCountText) + overflowModCountDisplay.Show(); else - { - count.Text = "off"; - count.FadeColour(colours.GrayF, 200, Easing.OutQuint); - circle.FadeColour(colours.Gray4, 200, Easing.OutQuint); - } + overflowModCountDisplay.Hide(); } - - private IEnumerable allAvailableAndValidMods => freeModSelectOverlay.AllAvailableMods - .Where(state => state.ValidForSelection.Value) - .Select(state => state.Mod); } } diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs index c4edcec97697..e3ad226b18d2 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs @@ -4,31 +4,23 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Localisation; -using osu.Game.Screens.Select; +using osu.Game.Screens.Footer; namespace osu.Game.Screens.OnlinePlay { - public partial class FooterButtonFreestyle : FooterButton + public partial class FooterButtonFreestyle : ScreenFooterButton { public readonly Bindable Freestyle = new Bindable(); - protected override bool IsActive => Freestyle.Value; - public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); } - private OsuSpriteText text = null!; - private Circle circle = null!; - [Resolved] private OsuColour colours { get; set; } = null!; @@ -41,36 +33,9 @@ public FooterButtonFreestyle() [BackgroundDependencyLoader] private void load() { - ButtonContentContainer.AddRange(new[] - { - new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - circle = new Circle - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = colours.YellowDark, - RelativeSizeAxes = Axes.Both, - }, - text = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Padding = new MarginPadding(5), - UseFullGlyphHeight = false, - } - } - } - }); - - SelectedColour = colours.Yellow; - DeselectedColour = SelectedColour.Opacity(0.5f); - Text = @"freestyle"; + Text = OnlinePlayStrings.FooterButtonFreestyle; + Icon = FontAwesome.Solid.ExchangeAlt; + AccentColour = colours.Lime1; TooltipText = MultiplayerMatchStrings.FreestyleButtonTooltip; } @@ -79,23 +44,10 @@ protected override void LoadComplete() { base.LoadComplete(); - Freestyle.BindValueChanged(_ => updateDisplay(), true); - } - - private void updateDisplay() - { - if (Freestyle.Value) - { - text.Text = "on"; - text.FadeColour(colours.Gray2, 200, Easing.OutQuint); - circle.FadeColour(colours.Yellow, 200, Easing.OutQuint); - } - else + Freestyle.BindValueChanged(active => { - text.Text = "off"; - text.FadeColour(colours.GrayF, 200, Easing.OutQuint); - circle.FadeColour(colours.Gray4, 200, Easing.OutQuint); - } + OverlayState.Value = active.NewValue ? Visibility.Visible : Visibility.Hidden; + }, true); } } } diff --git a/osu.Game/Screens/OnlinePlay/Header.cs b/osu.Game/Screens/OnlinePlay/Header.cs index 825f80939758..a646dc73c9f4 100644 --- a/osu.Game/Screens/OnlinePlay/Header.cs +++ b/osu.Game/Screens/OnlinePlay/Header.cs @@ -43,7 +43,18 @@ public Header(LocalisableString mainTitle, ScreenStack? stack) } } - private void updateSubScreenTitle() => title.Screen = stack?.CurrentScreen as IOnlinePlaySubScreen; + private void updateSubScreenTitle() + { + IOnlinePlaySubScreen? screen = stack?.CurrentScreen as IOnlinePlaySubScreen; + + if (screen?.ShowHeaderLine == true) + { + title.FadeIn(200, Easing.OutQuint); + title.Screen = screen; + } + else + title.FadeOut(200, Easing.OutQuint); + } private partial class MultiHeaderTitle : CompositeDrawable { diff --git a/osu.Game/Screens/OnlinePlay/IOnlinePlaySubScreen.cs b/osu.Game/Screens/OnlinePlay/IOnlinePlaySubScreen.cs index c528e3952ea6..265a2e18e4bd 100644 --- a/osu.Game/Screens/OnlinePlay/IOnlinePlaySubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/IOnlinePlaySubScreen.cs @@ -8,5 +8,7 @@ public interface IOnlinePlaySubScreen : IOsuScreen string Title { get; } string ShortTitle { get; } + + bool ShowHeaderLine => true; } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs index fe03fca4b8c8..84b420d79114 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs @@ -25,6 +25,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.Chat; using osu.Game.Online.Rooms; @@ -430,7 +431,7 @@ public virtual MenuItem[] ContextMenuItems { items.AddRange([ new OsuMenuItem("View in browser", MenuItemType.Standard, () => game?.OpenUrlExternally(url)), - new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyToClipboard(url)) + new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard(url)) ]); } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs index 093d9f611751..0f20ec9a54f6 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs @@ -5,15 +5,15 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Online.Matchmaking; using osu.Game.Overlays; -using osu.Game.Screens.OnlinePlay.Match; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro @@ -41,28 +41,36 @@ public partial class ScreenIntro : OsuScreen [Resolved] private MusicController musicController { get; set; } = null!; + private readonly MatchmakingPoolType poolType; + private Sample? dateWindupSample; private Sample? dateImpactSample; private Sample? beatmapWindupSample; - private Sample? beatmapImpactSample; private SampleChannel? dateWindupChannel; private SampleChannel? dateImpactChannel; private SampleChannel? beatmapWindupChannel; - private SampleChannel? beatmapImpactChannel; private IDisposable? duckOperation; - protected override BackgroundScreen CreateBackground() => new MatchmakingIntroBackgroundScreen(colourProvider); + protected override BackgroundScreen CreateBackground() => new MatchmakingBackgroundScreen(colourProvider); - public ScreenIntro() + public ScreenIntro(MatchmakingPoolType poolType) { + this.poolType = poolType; ValidForResume = false; } [BackgroundDependencyLoader] private void load(AudioManager audio) { + string poolTypeName = poolType switch + { + MatchmakingPoolType.QuickPlay => "Quick Play", + MatchmakingPoolType.RankedPlay => "Ranked Play", + _ => throw new ArgumentOutOfRangeException() + }; + InternalChildren = new Drawable[] { introContent = new Container @@ -100,7 +108,7 @@ private void load(AudioManager audio) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Quick Play", + Text = poolTypeName, Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, Shear = -OsuGame.SHEAR, Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), @@ -116,7 +124,6 @@ private void load(AudioManager audio) dateWindupSample = audio.Samples.Get(@"DailyChallenge/date-windup"); dateImpactSample = audio.Samples.Get(@"DailyChallenge/date-impact"); beatmapWindupSample = audio.Samples.Get(@"DailyChallenge/beatmap-windup"); - beatmapImpactSample = audio.Samples.Get(@"DailyChallenge/beatmap-impact"); } public override void OnEntering(ScreenTransitionEvent e) @@ -195,7 +202,7 @@ private void beginAnimation() Schedule(() => { if (this.IsCurrentScreen()) - this.Push(new ScreenQueue()); + this.Push(new ScreenQueue(poolType)); }); } } @@ -220,12 +227,6 @@ private void playBeatmapWindupSample() beatmapWindupChannel?.Play(); } - private void playBeatmapImpactSample() - { - beatmapImpactChannel = beatmapImpactSample?.GetChannel(); - beatmapImpactChannel?.Play(); - } - protected override void Dispose(bool isDisposing) { resetAudio(); @@ -237,30 +238,7 @@ private void resetAudio() dateWindupChannel?.Stop(); dateImpactChannel?.Stop(); beatmapWindupChannel?.Stop(); - beatmapImpactChannel?.Stop(); duckOperation?.Dispose(); } - - private partial class MatchmakingIntroBackgroundScreen : RoomBackgroundScreen - { - private readonly OverlayColourProvider colourProvider; - - public MatchmakingIntroBackgroundScreen(OverlayColourProvider colourProvider) - : base(null) - { - this.colourProvider = colourProvider; - } - - [BackgroundDependencyLoader] - private void load() - { - AddInternal(new Box - { - Depth = float.MinValue, - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5.Opacity(0.6f), - }); - } - } } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/InverseScalingDrawSizePreservingFillContainer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/InverseScalingDrawSizePreservingFillContainer.cs new file mode 100644 index 000000000000..8e151021104f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/InverseScalingDrawSizePreservingFillContainer.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking +{ + public partial class InverseScalingDrawSizePreservingFillContainer : ScalingContainer.ScalingDrawSizePreservingFillContainer + { + public InverseScalingDrawSizePreservingFillContainer() + : base(true) + { + } + + protected override void Update() + { + Size = new Vector2(CurrentScale); + Scale = new Vector2(1 / CurrentScale); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapCardMatchmaking.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapCardMatchmaking.cs deleted file mode 100644 index 1c8194d587e0..000000000000 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapCardMatchmaking.cs +++ /dev/null @@ -1,466 +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.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Beatmaps.Drawables.Cards; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Localisation; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Overlays; -using osu.Game.Overlays.BeatmapSet; -using osu.Game.Resources.Localisation.Web; -using osuTK; - -namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect -{ - public partial class BeatmapCardMatchmaking : BeatmapCard - { - private readonly APIBeatmap beatmap; - - protected override Drawable IdleContent => idleBottomContent; - protected override Drawable DownloadInProgressContent => downloadProgressBar; - - public const float HEIGHT = 80; - - [Cached] - private readonly BeatmapCardContent content; - - private BeatmapCardThumbnail thumbnail = null!; - private CollapsibleButtonContainer buttonContainer = null!; - - private FillFlowContainer idleBottomContent = null!; - private BeatmapCardDownloadProgressBar downloadProgressBar = null!; - - public AvatarOverlay SelectionOverlay = null!; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - [Resolved] - private BeatmapSetOverlay? beatmapSetOverlay { get; set; } - - public BeatmapCardMatchmaking(APIBeatmap beatmap) - : base(beatmap.BeatmapSet!, false) - { - this.beatmap = beatmap; - content = new BeatmapCardContent(HEIGHT); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Width = WIDTH; - Height = HEIGHT; - - FillFlowContainer leftIconArea = null!; - FillFlowContainer titleBadgeArea = null!; - GridContainer artistContainer = null!; - - Child = content.With(c => - { - c.MainContent = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - thumbnail = new BeatmapCardThumbnail(BeatmapSet, BeatmapSet, keepLoaded: true) - { - Name = @"Left (icon) area", - Size = new Vector2(HEIGHT), - Padding = new MarginPadding { Right = CORNER_RADIUS }, - Child = leftIconArea = new FillFlowContainer - { - Margin = new MarginPadding(4), - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(1) - } - }, - buttonContainer = new CollapsibleButtonContainer(BeatmapSet, allowNavigationToBeatmap: false, keepBackgroundLoaded: true) - { - X = HEIGHT - CORNER_RADIUS, - Width = WIDTH - HEIGHT + CORNER_RADIUS, - FavouriteState = { BindTarget = FavouriteState }, - ButtonsCollapsedWidth = 0, - ButtonsExpandedWidth = 24, - Children = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] - { - new TruncatingSpriteText - { - Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title), - Font = OsuFont.Default.With(size: 18f, weight: FontWeight.SemiBold), - RelativeSizeAxes = Axes.X, - }, - titleBadgeArea = new FillFlowContainer - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - } - } - } - }, - artistContainer = new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize) - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new[] - { - new TruncatingSpriteText - { - Text = createArtistText(), - Font = OsuFont.Default.With(size: 14f, weight: FontWeight.SemiBold), - RelativeSizeAxes = Axes.X, - }, - Empty() - }, - } - }, - new LinkFlowContainer(s => - { - s.Shadow = false; - s.Font = OsuFont.GetFont(size: 11f, weight: FontWeight.SemiBold); - }).With(d => - { - d.AutoSizeAxes = Axes.Both; - d.Margin = new MarginPadding { Top = 1 }; - d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); - d.AddUserLink(BeatmapSet.Author); - }), - } - }, - new Container - { - Name = @"Bottom content", - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Children = new Drawable[] - { - idleBottomContent = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 2), - AlwaysPresent = true, - Children = new Drawable[] - { - new Container - { - Masking = true, - CornerRadius = CORNER_RADIUS, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - new Box - { - Colour = colours.ForStarDifficulty(beatmap.StarRating).Darken(0.8f), - RelativeSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - Padding = new MarginPadding(4), - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(6, 0), - Children = new Drawable[] - { - new StarRatingDisplay(new StarDifficulty(beatmap.StarRating, 0), StarRatingDisplaySize.Small, animated: true) - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Scale = new Vector2(0.9f), - }, - new TruncatingSpriteText - { - Text = beatmap.DifficultyName, - Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - } - } - }, - } - }, - } - }, - downloadProgressBar = new BeatmapCardDownloadProgressBar - { - RelativeSizeAxes = Axes.X, - Height = 5, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - State = { BindTarget = DownloadTracker.State }, - Progress = { BindTarget = DownloadTracker.Progress } - } - } - }, - SelectionOverlay = new AvatarOverlay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - } - } - } - } - }; - c.Expanded.BindTarget = Expanded; - }); - - if (BeatmapSet.HasVideo) - leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(16) }); - - if (BeatmapSet.HasStoryboard) - leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(16) }); - - if (BeatmapSet.FeaturedInSpotlight) - { - titleBadgeArea.Add(new SpotlightBeatmapBadge - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 4 } - }); - } - - if (BeatmapSet.HasExplicitContent) - { - titleBadgeArea.Add(new ExplicitContentBeatmapBadge - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 4 } - }); - } - - if (BeatmapSet.TrackId != null) - { - artistContainer.Content[0][1] = new FeaturedArtistBeatmapBadge - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 4 } - }; - } - } - - private LocalisableString createArtistText() - { - var romanisableArtist = new RomanisableString(BeatmapSet.ArtistUnicode, BeatmapSet.Artist); - return BeatmapsetsStrings.ShowDetailsByArtist(romanisableArtist); - } - - protected override void UpdateState() - { - base.UpdateState(); - - bool showDetails = IsHovered; - - buttonContainer.ShowDetails.Value = showDetails; - thumbnail.Dimmed.Value = showDetails; - } - - public override MenuItem[] ContextMenuItems - { - get - { - List items = new List - { - new OsuMenuItem(ContextMenuStrings.ViewBeatmap, MenuItemType.Highlighted, () => beatmapSetOverlay?.FetchAndShowBeatmap(beatmap.OnlineID)) - }; - - foreach (var button in buttonContainer.Buttons) - { - if (button.Enabled.Value) - items.Add(new OsuMenuItem(button.TooltipText.ToSentence(), MenuItemType.Standard, () => button.TriggerClick())); - } - - return items.ToArray(); - } - } - - public partial class AvatarOverlay : CompositeDrawable - { - private readonly Container avatars; - - private Sample? userAddedSample; - private double? lastSamplePlayback; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - public AvatarOverlay() - { - AutoSizeAxes = Axes.Both; - - InternalChild = avatars = new Container - { - AutoSizeAxes = Axes.X, - Height = SelectionAvatar.AVATAR_SIZE, - }; - - Padding = new MarginPadding { Vertical = 5 }; - } - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - userAddedSample = audio.Samples.Get(@"Multiplayer/player-ready"); - } - - public bool AddUser(APIUser user) - { - if (avatars.Any(a => a.User.Id == user.Id)) - return false; - - var avatar = new SelectionAvatar(user, user.Equals(api.LocalUser.Value)); - - avatars.Add(avatar); - - if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME) - { - userAddedSample?.Play(); - lastSamplePlayback = Time.Current; - } - - updateAvatarLayout(); - - avatar.FinishTransforms(); - - return true; - } - - public bool RemoveUser(int id) - { - if (avatars.SingleOrDefault(a => a.User.Id == id) is not SelectionAvatar avatar) - return false; - - avatar.PopOutAndExpire(); - avatars.ChangeChildDepth(avatar, float.MaxValue); - - updateAvatarLayout(); - - return true; - } - - private void updateAvatarLayout() - { - const double stagger = 30; - const float spacing = 4; - - double delay = 0; - float x = 0; - - for (int i = avatars.Count - 1; i >= 0; i--) - { - var avatar = avatars[i]; - - if (avatar.Expired) - continue; - - avatar.Delay(delay).MoveToX(x, 500, Easing.OutElasticQuarter); - - x -= avatar.LayoutSize.X + spacing; - - delay += stagger; - } - } - - public partial class SelectionAvatar : CompositeDrawable - { - public const float AVATAR_SIZE = 30; - - public APIUser User { get; } - - public bool Expired { get; private set; } - - private readonly MatchmakingAvatar avatar; - - public SelectionAvatar(APIUser user, bool isOwnUser) - { - User = user; - Size = new Vector2(AVATAR_SIZE); - - InternalChild = avatar = new MatchmakingAvatar(user, isOwnUser) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - avatar.ScaleTo(0) - .ScaleTo(1, 500, Easing.OutElasticHalf) - .FadeIn(200); - } - - public void PopOutAndExpire() - { - avatar.ScaleTo(0, 400, Easing.OutExpo); - - this.FadeOut(100).Expire(); - Expired = true; - } - } - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs index 1d3153915f44..27cb78e57943 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs @@ -6,6 +6,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using Microsoft.Toolkit.HighPerformance; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -33,17 +34,18 @@ public partial class BeatmapSelectGrid : CompositeDrawable public event Action? ItemSelected; - private readonly Dictionary panelLookup = new Dictionary(); + private readonly Dictionary panelLookup = new Dictionary(); + private readonly Dictionary playlistItems = new Dictionary(); + private MatchmakingSelectPanelRandom randomPanel = null!; private readonly PanelGridContainer panelGridContainer; - private readonly Container rollContainer; + private readonly Container rollContainer; private readonly OsuScrollContainer scroll; private bool allowSelection = true; private readonly Sample?[] spinSamples = new Sample?[5]; private static readonly int[] spin_sample_sequence = [0, 1, 2, 3, 4, 2, 3, 4]; - private Sample? resultSample; private Sample? swooshSample; private double? lastSamplePlayback; @@ -63,7 +65,7 @@ public BeatmapSelectGrid() Spacing = new Vector2(panel_spacing) }, }, - rollContainer = new Container + rollContainer = new Container { RelativeSizeAxes = Axes.Both, Masking = true, @@ -77,13 +79,37 @@ private void load(AudioManager audio) for (int i = 0; i < spinSamples.Length; i++) spinSamples[i] = audio.Samples.Get($@"Multiplayer/Matchmaking/Selection/roulette-{i}"); - resultSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Selection/roulette-result"); swooshSample = audio.Samples.Get(@"SongSelect/options-pop-out"); } - protected override void LoadComplete() + public void AddItems(IEnumerable items) { - base.LoadComplete(); + foreach (var item in items) + { + playlistItems[item.ID] = item; + + var panel = panelLookup[item.ID] = new MatchmakingSelectPanelBeatmap(item) + { + AllowSelection = allowSelection, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = i => ItemSelected?.Invoke(i), + Depth = -(float)item.PlaylistItem.StarRating + }; + + panelGridContainer.Add(panel); + panelGridContainer.SetLayoutPosition(panel, (float)panel.Item.StarRating); + } + + panelLookup[-1] = randomPanel = new MatchmakingSelectPanelRandom(new MultiplayerPlaylistItem { ID = -1 }) + { + AllowSelection = allowSelection, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = i => ItemSelected?.Invoke(i), + }; + panelGridContainer.Add(randomPanel); + panelGridContainer.SetLayoutPosition(randomPanel, float.MinValue); const double enter_duration = 500; @@ -99,32 +125,12 @@ protected override void LoadComplete() panel.FadeInAndEnterFromBelow(duration: enter_duration, delay: delay); } - }); - } - - public void AddItem(MultiplayerPlaylistItem item) - { - var panel = panelLookup[item.ID] = new BeatmapSelectPanel(item) - { - AllowSelection = allowSelection, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Action = ItemSelected, - }; - panelGridContainer.Add(panel); - panelGridContainer.SetLayoutPosition(panel, (float)item.StarRating); - } - - public void RemoveItem(long id) - { - if (!panelLookup.Remove(id, out var panel)) - return; - - panel.Expire(); + panelsLoaded.SetResult(); + }); } - public void SetUserSelection(APIUser user, long itemId, bool selected) + public void SetUserSelection(APIUser user, long itemId, bool selected) => whenPanelsLoaded(() => { if (!panelLookup.TryGetValue(itemId, out var panel)) return; @@ -133,13 +139,13 @@ public void SetUserSelection(APIUser user, long itemId, bool selected) panel.AddUser(user); else panel.RemoveUser(user); - } + }); - public void RollAndDisplayFinalBeatmap(long[] candidateItemIds, long finalItemId) + public void RollAndDisplayFinalBeatmap(long[] candidateItemIds, long candidateItemId, long gameplayItemId) => whenPanelsLoaded(() => { Debug.Assert(candidateItemIds.Length >= 1); - Debug.Assert(candidateItemIds.Contains(finalItemId)); - Debug.Assert(panelLookup.ContainsKey(finalItemId)); + Debug.Assert(candidateItemIds.Contains(candidateItemId)); + Debug.Assert(panelLookup.ContainsKey(candidateItemId)); Debug.Assert(candidateItemIds.All(id => panelLookup.ContainsKey(id))); allowSelection = false; @@ -151,18 +157,18 @@ public void RollAndDisplayFinalBeatmap(long[] candidateItemIds, long finalItemId this.Delay(ARRANGE_DELAY) .Schedule(() => ArrangeItemsForRollAnimation()) .Delay(arrange_duration + present_beatmap_delay) - .Schedule(() => PresentUnanimouslyChosenBeatmap(finalItemId)); + .Schedule(() => PresentUnanimouslyChosenBeatmap(candidateItemId, gameplayItemId)); } else { this.Delay(ARRANGE_DELAY) .Schedule(() => ArrangeItemsForRollAnimation()) .Delay(arrange_duration) - .Schedule(() => PlayRollAnimation(finalItemId, roll_duration)) + .Schedule(() => PlayRollAnimation(candidateItemId, roll_duration)) .Delay(roll_duration + present_beatmap_delay) - .Schedule(() => PresentRolledBeatmap(finalItemId)); + .Schedule(() => PresentRolledBeatmap(candidateItemId, gameplayItemId)); } - } + }); internal void TransferCandidatePanelsToRollContainer(long[] candidateItemIds, double duration = hide_duration) { @@ -171,7 +177,7 @@ internal void TransferCandidatePanelsToRollContainer(long[] candidateItemIds, do var rng = new Random(); - var remainingPanels = new List(); + var remainingPanels = new List(); foreach (var panel in panelGridContainer.Children.ToArray()) { @@ -211,7 +217,7 @@ internal void ArrangeItemsForRollAnimation(double duration = arrange_duration, d { var panel = rollContainer.Children[i]; - var position = positions[i] * (BeatmapSelectPanel.SIZE + new Vector2(panel_spacing)); + var position = positions[i] * (MatchmakingSelectPanel.SIZE + new Vector2(panel_spacing)); panel.MoveTo(position, duration + stagger * i, new SplitEasingFunction(Easing.InCubic, Easing.OutExpo, 0.3f)); @@ -280,7 +286,7 @@ internal void PlayRollAnimation(long finalItem, double duration = roll_duration) while ((numSteps - 1) % rollContainer.Children.Count != finalItemIndex) numSteps++; - BeatmapSelectPanel? lastPanel = null; + MatchmakingSelectPanel? lastPanel = null; for (int i = 0; i < numSteps; i++) { @@ -307,13 +313,14 @@ internal void PlayRollAnimation(long finalItem, double duration = roll_duration) } } - internal void PresentRolledBeatmap(long finalItem) + internal void PresentRolledBeatmap(long candidateItem, long gameplayItem) { - Debug.Assert(rollContainer.Children.Any(it => it.Item.ID == finalItem)); + Debug.Assert(rollContainer.Children.Any(it => it.Item.ID == candidateItem)); + Debug.Assert(playlistItems.ContainsKey(gameplayItem)); foreach (var panel in rollContainer.Children) { - if (panel.Item.ID != finalItem) + if (panel.Item.ID != candidateItem) { panel.FadeOut(200); panel.PopOutAndExpire(easing: Easing.InQuad); @@ -325,23 +332,29 @@ internal void PresentRolledBeatmap(long finalItem) { rollContainer.ChangeChildDepth(panel, float.MinValue); - panel.ShowChosenBorder(); - panel.MoveTo(Vector2.Zero, 1000, Easing.OutExpo) - .ScaleTo(1.5f, 1000, Easing.OutExpo); + var item = playlistItems[gameplayItem]; - resultSample?.Play(); + panel.PresentAsChosenBeatmap(item); }); } } - internal void PresentUnanimouslyChosenBeatmap(long finalItem) + internal void PresentUnanimouslyChosenBeatmap(long candidateItem, long gameplayItem) { // TODO: display special animation in this case - PresentRolledBeatmap(finalItem); + PresentRolledBeatmap(candidateItem, gameplayItem); } - private partial class PanelGridContainer : FillFlowContainer + private readonly TaskCompletionSource panelsLoaded = new TaskCompletionSource(); + + private void whenPanelsLoaded(Action action) => Task.Run(async () => + { + await panelsLoaded.Task.ConfigureAwait(false); + Schedule(action); + }); + + private partial class PanelGridContainer : FillFlowContainer { public bool LayoutDisabled; diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectPanel.cs deleted file mode 100644 index aa0329ad94d0..000000000000 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectPanel.cs +++ /dev/null @@ -1,241 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using osu.Framework.Allocation; -using osu.Framework.Extensions; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Beatmaps.Drawables.Cards; -using osu.Game.Database; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Rooms; -using osu.Game.Overlays; -using osuTK; -using osuTK.Graphics; -using osuTK.Input; - -namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect -{ - public partial class BeatmapSelectPanel : Container - { - public static readonly Vector2 SIZE = new Vector2(BeatmapCard.WIDTH, BeatmapCardNormal.HEIGHT); - - public bool AllowSelection { get; set; } - - public readonly MultiplayerPlaylistItem Item; - - public Action? Action { private get; init; } - - private const float border_width = 3; - - private Container scaleContainer = null!; - private Drawable lighting = null!; - - private Container border = null!; - private Container mainContent = null!; - - private readonly List users = new List(); - - private BeatmapCardMatchmaking? card; - - public BeatmapSelectPanel(MultiplayerPlaylistItem item) - { - Item = item; - Size = SIZE; - } - - [BackgroundDependencyLoader] - private void load(BeatmapLookupCache lookupCache, OverlayColourProvider colourProvider) - { - InternalChild = scaleContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new[] - { - mainContent = new Container - { - Masking = true, - CornerRadius = BeatmapCard.CORNER_RADIUS, - CornerExponent = 10, - RelativeSizeAxes = Axes.Both, - Children = new[] - { - lighting = new Box - { - Blending = BlendingParameters.Additive, - RelativeSizeAxes = Axes.Both, - Alpha = 0, - }, - } - }, - border = new Container - { - Alpha = 0, - Masking = true, - CornerRadius = BeatmapCard.CORNER_RADIUS, - CornerExponent = 10, - Blending = BlendingParameters.Additive, - RelativeSizeAxes = Axes.Both, - BorderThickness = border_width, - BorderColour = colourProvider.Light1, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 40, - Roundness = 300, - Colour = colourProvider.Light3.Opacity(0.1f), - }, - Children = new Drawable[] - { - new Box - { - AlwaysPresent = true, - Alpha = 0, - Colour = Color4.Black, - RelativeSizeAxes = Axes.Both, - }, - } - }, - } - }; - lookupCache.GetBeatmapAsync(Item.BeatmapID).ContinueWith(b => Schedule(() => - { - Debug.Assert(card == null); - - APIBeatmap beatmap = b.GetResultSafely() ?? new APIBeatmap - { - BeatmapSet = new APIBeatmapSet - { - Title = "unknown beatmap", - TitleUnicode = "unknown beatmap", - Artist = "unknown artist", - ArtistUnicode = "unknown artist", - } - }; - - beatmap.StarRating = Item.StarRating; - - mainContent.Add(card = new BeatmapCardMatchmaking(beatmap) - { - Depth = float.MaxValue, - Action = () => - { - if (AllowSelection) - Action?.Invoke(Item); - }, - }); - - foreach (var user in users) - card.SelectionOverlay.AddUser(user); - })); - } - - public void AddUser(APIUser user) - { - users.Add(user); - card?.SelectionOverlay.AddUser(user); - } - - public void RemoveUser(APIUser user) - { - users.Remove(user); - card?.SelectionOverlay.RemoveUser(user.Id); - } - - protected override bool OnHover(HoverEvent e) - { - if (AllowSelection) - { - lighting.FadeTo(0.2f, 50) - .Then() - .FadeTo(0.1f, 300); - return true; - } - - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - base.OnHoverLost(e); - - lighting.FadeOut(200); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - if (AllowSelection && e.Button == MouseButton.Left) - scaleContainer.ScaleTo(0.95f, 400, Easing.OutExpo); - - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - base.OnMouseUp(e); - - if (e.Button == MouseButton.Left) - scaleContainer.ScaleTo(1f, 500, Easing.OutElasticHalf); - } - - protected override bool OnClick(ClickEvent e) - { - if (AllowSelection) - { - lighting.FadeTo(0.5f, 50) - .Then() - .FadeTo(0.1f, 400); - } - - // pass through to let the beatmap card handle actual click. - return false; - } - - public void ShowChosenBorder() - { - border.FadeTo(1, 1000, Easing.OutQuint); - } - - public void ShowBorder() - { - border.FadeTo(1, 80, Easing.OutQuint) - .Then() - .FadeTo(0.7f, 800, Easing.OutQuint); - } - - public void HideBorder() - { - border.FadeOut(500, Easing.OutQuint); - } - - public void FadeInAndEnterFromBelow(double duration = 500, double delay = 0, float distance = 200) - { - scaleContainer - .FadeOut() - .MoveToY(distance) - .Delay(delay) - .FadeIn(duration / 2) - .MoveToY(0, duration, Easing.OutExpo); - } - - public void PopOutAndExpire(double duration = 400, double delay = 0, Easing easing = Easing.InCubic) - { - AllowSelection = false; - - scaleContainer.Delay(delay) - .ScaleTo(0, duration, easing) - .FadeOut(duration); - - this.Delay(delay + duration).FadeOut().Expire(); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingPlaylistItem.cs new file mode 100644 index 000000000000..6b7fb9f21eb1 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingPlaylistItem.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.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public record MatchmakingPlaylistItem(MultiplayerPlaylistItem PlaylistItem, APIBeatmap Beatmap, Mod[] Mods) + { + public long ID => PlaylistItem.ID; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.cs new file mode 100644 index 000000000000..48c64f2f6674 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.cs @@ -0,0 +1,156 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanel + { + public abstract partial class CardContent : CompositeDrawable + { + public abstract AvatarOverlay SelectionOverlay { get; } + + protected CardContent() + { + RelativeSizeAxes = Axes.Both; + } + + public partial class AvatarOverlay : CompositeDrawable + { + private readonly Container avatars; + + private Sample? userAddedSample; + private double? lastSamplePlayback; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public AvatarOverlay() + { + AutoSizeAxes = Axes.Both; + + InternalChild = avatars = new Container + { + AutoSizeAxes = Axes.X, + Height = SelectionAvatar.AVATAR_SIZE, + }; + + Padding = new MarginPadding { Vertical = 5 }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + userAddedSample = audio.Samples.Get(@"Multiplayer/player-ready"); + } + + public bool AddUser(APIUser user) + { + if (avatars.Any(a => a.User.Id == user.Id)) + return false; + + var avatar = new SelectionAvatar(user, user.Equals(api.LocalUser.Value)); + + avatars.Add(avatar); + + if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME) + { + userAddedSample?.Play(); + lastSamplePlayback = Time.Current; + } + + updateAvatarLayout(); + + avatar.FinishTransforms(); + + return true; + } + + public bool RemoveUser(int id) + { + if (avatars.SingleOrDefault(a => a.User.Id == id) is not SelectionAvatar avatar) + return false; + + avatar.PopOutAndExpire(); + avatars.ChangeChildDepth(avatar, float.MaxValue); + + updateAvatarLayout(); + + return true; + } + + private void updateAvatarLayout() + { + const double stagger = 30; + const float spacing = 4; + + double delay = 0; + float x = 0; + + for (int i = avatars.Count - 1; i >= 0; i--) + { + var avatar = avatars[i]; + + if (avatar.Expired) + continue; + + avatar.Delay(delay).MoveToX(x, 500, Easing.OutElasticQuarter); + + x -= avatar.LayoutSize.X + spacing; + + delay += stagger; + } + } + + public partial class SelectionAvatar : CompositeDrawable + { + public const float AVATAR_SIZE = 30; + + public APIUser User { get; } + + public bool Expired { get; private set; } + + private readonly MatchmakingAvatar avatar; + + public SelectionAvatar(APIUser user, bool isOwnUser) + { + User = user; + Size = new Vector2(AVATAR_SIZE); + + InternalChild = avatar = new MatchmakingAvatar(user, isOwnUser) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + avatar.ScaleTo(0) + .ScaleTo(1, 500, Easing.OutElasticHalf) + .FadeIn(200); + } + + public void PopOutAndExpire() + { + avatar.ScaleTo(0, 400, Easing.OutExpo); + + this.FadeOut(100).Expire(); + Expired = true; + } + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs new file mode 100644 index 000000000000..e2d5fa7890bf --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs @@ -0,0 +1,475 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapSet; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanel + { + public partial class CardContentBeatmap : CardContent, IHasContextMenu + { + public override AvatarOverlay SelectionOverlay => selectionOverlay; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private BeatmapSetOverlay? beatmapSetOverlay { get; set; } + + private readonly IBindable downloadState = new Bindable(); + private readonly IBindableNumber downloadProgress = new BindableDouble(); + private readonly Bindable favouriteState = new Bindable(); + private readonly APIBeatmapSet beatmapSet; + private readonly APIBeatmap beatmap; + private readonly Mod[] mods; + + private BeatmapCardThumbnail thumbnail = null!; + private CollapsibleButtonContainer buttonContainer = null!; + private FillFlowContainer idleBottomContent = null!; + private BeatmapCardDownloadProgressBar downloadProgressBar = null!; + private AvatarOverlay selectionOverlay = null!; + private OsuTextFlowContainer beatmapAttributesText = null!; + + public CardContentBeatmap(APIBeatmap beatmap, Mod[] mods) + { + this.beatmap = beatmap; + this.mods = mods; + + beatmapSet = beatmap.BeatmapSet!; + favouriteState.Value = new BeatmapSetFavouriteState(beatmapSet.HasFavourited, beatmapSet.FavouriteCount); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + FillFlowContainer leftIconArea; + Container explicitBadgeArea; + + InternalChildren = new[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Children = new Drawable[] + { + new BeatmapDownloadTracker(beatmap.BeatmapSet!) + { + State = { BindTarget = downloadState }, + Progress = { BindTarget = downloadProgress }, + }, + thumbnail = new BeatmapCardThumbnail(beatmapSet, beatmapSet, keepLoaded: true) + { + Name = @"Left (icon) area", + Size = new Vector2(MatchmakingSelectPanel.HEIGHT), + Padding = new MarginPadding { Right = BeatmapCard.CORNER_RADIUS }, + Children = new Drawable[] + { + leftIconArea = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding(4), + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1) + }, + explicitBadgeArea = new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding(4), + } + } + }, + buttonContainer = new CollapsibleButtonContainer(beatmapSet, allowNavigationToBeatmap: false, keepBackgroundLoaded: true) + { + X = MatchmakingSelectPanel.HEIGHT - BeatmapCard.CORNER_RADIUS, + Width = BeatmapCard.WIDTH - MatchmakingSelectPanel.HEIGHT + BeatmapCard.CORNER_RADIUS, + FavouriteState = { BindTarget = favouriteState }, + ButtonsCollapsedWidth = 0, + ButtonsExpandedWidth = 24, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TruncatingSpriteText + { + Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title), + Font = OsuFont.Default.With(size: 18f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new TruncatingSpriteText + { + Text = BeatmapsetsStrings.ShowDetailsByArtist(new RomanisableString(beatmapSet.ArtistUnicode, beatmapSet.Artist)), + Font = OsuFont.Default.With(size: 14f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + }, + new TopTagPill(beatmap) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + } + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new LinkFlowContainer(s => + { + s.Shadow = false; + s.Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.Margin = new MarginPadding { Top = 1 }; + d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); + d.AddUserLink(beatmapSet.Author); + }), + beatmapAttributesText = new OsuTextFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + } + } + } + } + }, + new Container + { + Name = @"Bottom content", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Children = new Drawable[] + { + idleBottomContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2), + AlwaysPresent = true, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new Container + { + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + Colour = colours.ForStarDifficulty(beatmap.StarRating).Darken(0.8f), + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Padding = new MarginPadding(4), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(6, 0), + Children = new Drawable[] + { + new StarRatingDisplay(new StarDifficulty(beatmap.StarRating, 0), StarRatingDisplaySize.Small, animated: true) + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.9f), + }, + new TruncatingSpriteText + { + Text = beatmap.DifficultyName, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + } + }, + new Container + { + AutoSizeAxes = Axes.Both, + Alpha = mods.Length > 0 ? 1 : 0, + Child = new ModFlowDisplay + { + AutoSizeAxes = Axes.Both, + Scale = new Vector2(0.5f), + Margin = new MarginPadding { Left = 5 }, + Current = { Value = mods }, + } + } + }, + } + }, + } + }, + downloadProgressBar = new BeatmapCardDownloadProgressBar + { + RelativeSizeAxes = Axes.X, + Height = 5, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { BindTarget = downloadState }, + Progress = { BindTarget = downloadProgress } + } + } + }, + selectionOverlay = new AvatarOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Top = -20 } + } + } + }, + } + }, + selectionOverlay.CreateProxy() + }; + + if (beatmapSet.HasVideo) + leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(16) }); + + if (beatmapSet.HasStoryboard) + leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(16) }); + + if (beatmapSet.HasExplicitContent) + { + explicitBadgeArea.Add(new ExplicitContentBeatmapBadge + { + Margin = new MarginPadding { Left = 4 } + }); + } + + bool firstAttribute = true; + + foreach (var attribute in getBeatmapAttributes()) + { + if (!firstAttribute) + { + beatmapAttributesText.AddText(@" / ", s => + { + font(s, false); + s.Spacing = new Vector2(-2, 0); + }); + } + + beatmapAttributesText.AddText(attribute.heading, s => font(s, false)); + beatmapAttributesText.AddText(@" ", s => font(s, false)); + beatmapAttributesText.AddText(attribute.content, s => font(s, true)); + + firstAttribute = false; + + static void font(SpriteText s, bool bold) + => s.Font = OsuFont.Style.Caption2.With(weight: bold ? FontWeight.Bold : FontWeight.Regular); + } + } + + private (string heading, string content)[] getBeatmapAttributes() + { + BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(beatmap.Difficulty); + foreach (var mod in mods.OfType()) + mod.ApplyToDifficulty(adjustedDifficulty); + + switch (beatmap.Ruleset.OnlineID) + { + default: + return new (string heading, string content)[] + { + ("CS", $"{adjustedDifficulty.CircleSize:0.#}"), + ("AR", $"{adjustedDifficulty.ApproachRate:0.#}"), + ("OD", $"{adjustedDifficulty.OverallDifficulty:0.#}"), + }; + + case 1: + case 3: + return new (string heading, string content)[] + { + ("OD", $"{adjustedDifficulty.OverallDifficulty:0.#}"), + ("HP", $"{adjustedDifficulty.DrainRate:0.#}") + }; + + case 2: + return new (string heading, string content)[] + { + ("CS", $"{adjustedDifficulty.CircleSize:0.#}"), + ("AR", $"{adjustedDifficulty.ApproachRate:0.#}"), + }; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + downloadState.BindValueChanged(_ => updateState(), true); + + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + bool showDetails = IsHovered; + + buttonContainer.ShowDetails.Value = showDetails; + thumbnail.Dimmed.Value = showDetails; + + bool showProgress = downloadState.Value == DownloadState.Downloading || downloadState.Value == DownloadState.Importing; + + idleBottomContent.FadeTo(showProgress ? 0 : 1, 340, Easing.OutQuint); + downloadProgressBar.FadeTo(showProgress ? 1 : 0, 340, Easing.OutQuint); + } + + public MenuItem[] ContextMenuItems + { + get + { + List items = new List + { + new OsuMenuItem(ContextMenuStrings.ViewBeatmap, MenuItemType.Highlighted, () => beatmapSetOverlay?.FetchAndShowBeatmap(beatmap.OnlineID)) + }; + + foreach (var button in buttonContainer.Buttons) + { + if (button.Enabled.Value) + items.Add(new OsuMenuItem(button.TooltipText.ToSentence(), MenuItemType.Standard, () => button.TriggerClick())); + } + + return items.ToArray(); + } + } + + private partial class TopTagPill : CompositeDrawable, IHasTooltip + { + private readonly APIBeatmap beatmap; + + public TopTagPill(APIBeatmap beatmap) + { + this.beatmap = beatmap; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChild = new CircularContainer + { + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background1 + }, + new OsuSpriteText + { + Padding = new MarginPadding { Vertical = 3, Horizontal = 8 }, + Text = beatmap.GetTopUserTags().FirstOrDefault().Tag?.Name ?? string.Empty, + AlwaysPresent = true, + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption2, + UseFullGlyphHeight = false, + } + } + }; + } + + public LocalisableString TooltipText => string.Join('\n', beatmap.GetTopUserTags().Select(t => $"{t.Tag.Name} ({t.VoteCount})")); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.cs new file mode 100644 index 000000000000..3e4130fa8b04 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanel + { + public partial class CardContentRandom : CardContent + { + public override AvatarOverlay SelectionOverlay => selectionOverlay; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private AvatarOverlay selectionOverlay = null!; + public SpriteIcon Dice { get; private set; } = null!; + public OsuSpriteText Label { get; private set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Dark5, + }, + new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + }, + Label = new OsuSpriteText + { + Y = 20, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Random" + }, + Dice = new SpriteIcon + { + Y = -10, + Size = new Vector2(28), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = randomDiceIcon(), + }, + selectionOverlay = new AvatarOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Right = 5 } + } + } + }; + + Dice.Spin(10_000, RotationDirection.Clockwise); + } + + public void RollDice() + { + var icon = randomDiceIcon(); + + while (icon.Equals(Dice.Icon)) + icon = randomDiceIcon(); + + Dice.ScaleTo(0.65f, 60, Easing.Out) + .Then() + .Schedule(() => Dice.Icon = icon) + .ScaleTo(1f, 400, Easing.OutElasticHalf); + } + + private static IconUsage[] diceIcons => new[] + { + FontAwesome.Solid.DiceOne, + FontAwesome.Solid.DiceTwo, + FontAwesome.Solid.DiceThree, + FontAwesome.Solid.DiceFour, + FontAwesome.Solid.DiceFive, + FontAwesome.Solid.DiceSix, + }; + + private static IconUsage randomDiceIcon() => diceIcons[RNG.Next(diceIcons.Length)]; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs new file mode 100644 index 000000000000..4bee234786f4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs @@ -0,0 +1,205 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public abstract partial class MatchmakingSelectPanel : Container + { + public const float WIDTH = 345; + public const float HEIGHT = 80; + + public static readonly Vector2 SIZE = new Vector2(WIDTH, HEIGHT); + + public bool AllowSelection { get; set; } + + public readonly MultiplayerPlaylistItem Item; + + public Action? Action { private get; init; } + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private const float border_width = 3; + + protected Container ScaleContainer = null!; + private Drawable lighting = null!; + private Container border = null!; + + protected MatchmakingSelectPanel(MultiplayerPlaylistItem item) + { + Item = item; + Size = SIZE; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChildren = new Drawable[] + { + ScaleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + CornerExponent = 10, + Child = lighting = new Box + { + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + } + }, + Content, + border = new Container + { + Alpha = 0, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + CornerExponent = 10, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + BorderThickness = border_width, + BorderColour = colourProvider.Light1, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 40, + Roundness = 300, + Colour = colourProvider.Light3.Opacity(0.1f), + }, + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0, + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + } + }, + } + }, + new HoverClickSounds(), + }; + } + + // TODO: making these abstract for now but avatar overlay should really be owned by the top level class + public abstract void AddUser(APIUser user); + + public abstract void RemoveUser(APIUser user); + + protected override bool OnHover(HoverEvent e) + { + if (AllowSelection) + { + lighting.FadeTo(0.2f, 50) + .Then() + .FadeTo(0.1f, 300); + return true; + } + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + + lighting.FadeOut(200); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (AllowSelection && e.Button == MouseButton.Left) + ScaleContainer.ScaleTo(0.95f, 400, Easing.OutExpo); + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + base.OnMouseUp(e); + + if (e.Button == MouseButton.Left) + ScaleContainer.ScaleTo(1f, 500, Easing.OutElasticHalf); + } + + protected override bool OnClick(ClickEvent e) + { + if (AllowSelection) + { + lighting.FadeTo(0.5f, 50) + .Then() + .FadeTo(0.1f, 400); + + Action?.Invoke(Item); + } + + return true; + } + + public void ShowChosenBorder() + { + border.FadeTo(1, 1000, Easing.OutQuint); + } + + public void ShowBorder() + { + border.FadeTo(1, 80, Easing.OutQuint) + .Then() + .FadeTo(0.7f, 800, Easing.OutQuint); + } + + public void HideBorder() + { + border.FadeOut(500, Easing.OutQuint); + } + + public abstract void PresentAsChosenBeatmap(MatchmakingPlaylistItem playlistItem); + + public void FadeInAndEnterFromBelow(double duration = 500, double delay = 0, float distance = 200) + { + ScaleContainer + .FadeOut() + .MoveToY(distance) + .Delay(delay) + .FadeIn(duration / 2) + .MoveToY(0, duration, Easing.OutExpo); + } + + public void PopOutAndExpire(double duration = 400, double delay = 0, Easing easing = Easing.InCubic) + { + AllowSelection = false; + + ScaleContainer.Delay(delay) + .ScaleTo(0, duration, easing) + .FadeOut(duration); + + this.Delay(delay + duration).FadeOut().Expire(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelBeatmap.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelBeatmap.cs new file mode 100644 index 000000000000..0f70c1b2ed05 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelBeatmap.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanelBeatmap : MatchmakingSelectPanel + { + private readonly APIBeatmap beatmap; + private readonly Mod[] mods; + + public MatchmakingSelectPanelBeatmap(MatchmakingPlaylistItem item) + : base(item.PlaylistItem) + { + beatmap = item.Beatmap; + mods = item.Mods; + } + + private CardContent content = null!; + private Sample? resultSample; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + resultSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Selection/roulette-result"); + + Add(content = new CardContentBeatmap(beatmap, mods)); + } + + public override void PresentAsChosenBeatmap(MatchmakingPlaylistItem playlistItem) + { + ShowChosenBorder(); + this.MoveTo(Vector2.Zero, 1000, Easing.OutExpo) + .ScaleTo(1.5f, 1000, Easing.OutExpo); + + resultSample?.Play(); + } + + public override void AddUser(APIUser user) + { + content.SelectionOverlay.AddUser(user); + } + + public override void RemoveUser(APIUser user) + { + content.SelectionOverlay.RemoveUser(user.Id); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs new file mode 100644 index 000000000000..d7ec134066ce --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs @@ -0,0 +1,116 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanelRandom : MatchmakingSelectPanel + { + public MatchmakingSelectPanelRandom(MultiplayerPlaylistItem item) + : base(item) + { + } + + private CardContentRandom content = null!; + private Drawable diceProxy = null!; + private readonly List users = new List(); + + private Sample? resultSample; + private Sample? swooshSample; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + resultSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Selection/roulette-result"); + swooshSample = audio.Samples.Get(@"SongSelect/options-pop-out"); + + Add(content = new CardContentRandom()); + + AddInternal(diceProxy = content.Dice.CreateProxy()); + } + + public override void PresentAsChosenBeatmap(MatchmakingPlaylistItem playlistItem) + { + const double duration = 800; + + this.MoveTo(Vector2.Zero, 1000, Easing.OutExpo) + .ScaleTo(1.5f, 1000, Easing.OutExpo); + + content.Dice.MoveToY(-200, duration * 0.55, new CubicBezierEasingFunction(0.33, 1, 0.8, 1)) + .Then() + .Schedule(() => ChangeInternalChildDepth(diceProxy, float.MaxValue)) + .MoveToY(-DrawHeight / 2, duration * 0.45, new CubicBezierEasingFunction(0.2, 0, 0.55, 0)) + .Then() + .FadeOut() + .Expire(); + + content.Dice.RotateTo(content.Dice.Rotation - 360 * 5, duration * 1.3f, Easing.Out); + content.Label.FadeOut(200).Expire(); + + swooshSample?.Play(); + + Scheduler.AddDelayed(() => + { + content.Expire(); + + var flashLayer = new Box { RelativeSizeAxes = Axes.Both }; + + AddRange(new Drawable[] + { + new CardContentBeatmap(playlistItem.Beatmap, playlistItem.Mods), + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Child = flashLayer + } + }); + + foreach (var user in users) + content.SelectionOverlay.AddUser(user); + + flashLayer.FadeOutFromOne(1000, Easing.In); + + ScaleContainer.ScaleTo(0.92f, 120, Easing.Out) + .Then() + .ScaleTo(1f, 600, Easing.OutElasticHalf); + + resultSample?.Play(); + }, duration); + } + + public override void AddUser(APIUser user) + { + users.Add(user); + content.SelectionOverlay.AddUser(user); + } + + public override void RemoveUser(APIUser user) + { + users.Remove(user); + content.SelectionOverlay.RemoveUser(user.Id); + } + + protected override bool OnClick(ClickEvent e) + { + if (AllowSelection && content is CardContentRandom randomContent) + randomContent.RollDice(); + + return base.OnClick(e); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs index 4b3412551763..c7b0c66f46da 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs @@ -1,13 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect { @@ -17,10 +26,17 @@ public partial class SubScreenBeatmapSelect : MatchmakingSubScreen public override Drawable PlayersDisplayArea { get; } private readonly BeatmapSelectGrid beatmapSelectGrid; + private readonly LoadingSpinner loadingSpinner; [Resolved] private MultiplayerClient client { get; set; } = null!; + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + public SubScreenBeatmapSelect() { InternalChildren = new Drawable[] @@ -28,10 +44,20 @@ public SubScreenBeatmapSelect() new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 200 }, - Child = beatmapSelectGrid = new BeatmapSelectGrid + Padding = new MarginPadding { Horizontal = 250 }, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, + beatmapSelectGrid = new BeatmapSelectGrid + { + RelativeSizeAxes = Axes.Both, + }, + loadingSpinner = new LoadingSpinner + { + Size = new Vector2(64), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { Value = Visibility.Visible } + } }, }, new Container @@ -49,24 +75,52 @@ protected override void LoadComplete() { base.LoadComplete(); - client.ItemAdded += onItemAdded; - - foreach (var item in client.Room!.Playlist) - onItemAdded(item); - beatmapSelectGrid.ItemSelected += item => client.MatchmakingToggleSelection(item.ID); - client.MatchmakingItemSelected += onItemSelected; client.MatchmakingItemDeselected += onItemDeselected; + + Debug.Assert(client.Room != null); + + loadItems(client.Room.Playlist.Where(item => !item.Expired).ToArray()).FireAndForget(); } - private void onItemAdded(MultiplayerPlaylistItem item) => Scheduler.Add(() => + private async Task loadItems(MultiplayerPlaylistItem[] items) { - if (item.Expired) - return; + var beatmaps = await beatmapLookupCache.GetBeatmapsAsync(items.Select(it => it.BeatmapID).ToArray()).ConfigureAwait(false); + var matchmakingItems = new List(); + + foreach (var entry in items.Zip(beatmaps)) + { + var (item, beatmap) = entry; - beatmapSelectGrid.AddItem(item); - }); + beatmap ??= new APIBeatmap + { + BeatmapSet = new APIBeatmapSet + { + Title = "unknown beatmap", + TitleUnicode = "unknown beatmap", + Artist = "unknown artist", + ArtistUnicode = "unknown artist", + } + }; + + beatmap.StarRating = item.StarRating; + + Ruleset? ruleset = rulesetStore.GetRuleset(item.RulesetID)?.CreateInstance(); + + Debug.Assert(ruleset != null); + + Mod[] mods = item.RequiredMods.Select(m => m.ToMod(ruleset)).ToArray(); + + matchmakingItems.Add(new MatchmakingPlaylistItem(item, beatmap, mods)); + } + + Scheduler.Add(() => + { + loadingSpinner.Hide(); + beatmapSelectGrid.AddItems(matchmakingItems); + }); + } private void onItemSelected(int userId, long itemId) { @@ -80,7 +134,8 @@ private void onItemDeselected(int userId, long itemId) beatmapSelectGrid.SetUserSelection(user, itemId, false); } - public void RollFinalBeatmap(long[] candidateItems, long finalItem) => beatmapSelectGrid.RollAndDisplayFinalBeatmap(candidateItems, finalItem); + public void RollFinalBeatmap(long[] candidateItems, long candidateItem, long gameplayItem) => + beatmapSelectGrid.RollAndDisplayFinalBeatmap(candidateItems, candidateItem, gameplayItem); protected override void Dispose(bool isDisposing) { @@ -88,7 +143,6 @@ protected override void Dispose(bool isDisposing) if (client.IsNotNull()) { - client.ItemAdded -= onItemAdded; client.MatchmakingItemSelected -= onItemSelected; client.MatchmakingItemDeselected -= onItemDeselected; } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs index e0f46d89f095..194d0d578bfb 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs @@ -51,7 +51,7 @@ private void load(OsuColour colour) AddInternal(new Container { - Padding = new MarginPadding(2), + Padding = new MarginPadding(isOwnUser ? 2 : 0), RelativeSizeAxes = Axes.Both, Child = new CircularContainer { diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingBackgroundScreen.cs new file mode 100644 index 000000000000..bc832a934672 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingBackgroundScreen.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Overlays; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + public partial class MatchmakingBackgroundScreen : BackgroundScreen + { + private readonly OverlayColourProvider colourProvider; + + public MatchmakingBackgroundScreen(OverlayColourProvider colourProvider) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + InternalChild = new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("Backgrounds/bg1"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + Colour = colourProvider.Dark2 + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingChatDisplay.cs index 6a0164290704..98a08d6b17b8 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingChatDisplay.cs @@ -29,7 +29,7 @@ private void load(RealmKeyBindingStore keyBindingStore) resetPlaceholderText(); TextBox.HoldFocus = false; - TextBox.ReleaseFocusOnCommit = true; + TextBox.ReleaseFocusOnCommit = false; TextBox.Focus = () => TextBox.PlaceholderText = ChatStrings.InputPlaceholder; TextBox.FocusLost = resetPlaceholderText; diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs index 7b09a3565ca1..58d29220dc57 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs @@ -7,6 +7,8 @@ using System.Linq; using Humanizer; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,6 +17,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Screens; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -44,7 +47,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match /// public partial class PlayerPanel : OsuClickableContainer, IHasContextMenu { - private static readonly Vector2 size_horizontal = new Vector2(250, 100); + private static readonly Vector2 size_horizontal = new Vector2(300, 100); private static readonly Vector2 size_vertical = new Vector2(150, 200); private static readonly Vector2 avatar_size = new Vector2(80); @@ -114,6 +117,18 @@ public partial class PlayerPanel : OsuClickableContainer, IHasContextMenu private PlayerPanelDisplayMode displayMode = PlayerPanelDisplayMode.Horizontal; private bool hasQuit; + private enum InteractionSampleType + { + PlayerJump, + PlayerReJump, + OtherPlayerJump, + } + + private Dictionary interactionSamples = new Dictionary(); + private readonly Dictionary interactionSampleChannels = new Dictionary(); + private double samplePitch; + private double? lastSamplePlayback; + public PlayerPanel(MultiplayerRoomUser user) : base(HoverSampleSet.Button) { @@ -130,7 +145,7 @@ public PlayerPanel(MultiplayerRoomUser user) } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { Content.Masking = true; Content.CornerRadius = 10; @@ -221,13 +236,14 @@ private void load() Text = "-", Font = OsuFont.Style.Title.With(size: 55), }, - username = new OsuSpriteText + username = new TruncatingSpriteText { Alpha = 0, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Text = User.Username, Font = OsuFont.Style.Heading1, + MaxWidth = 120 }, scoreText = new OsuSpriteText { @@ -255,6 +271,13 @@ private void load() // Allow avatar to exist outside of masking for when it jumps around and stuff. AddInternal(avatar.CreateProxy()); + + interactionSamples = new Dictionary + { + { InteractionSampleType.PlayerJump, audio.Samples.Get(@"Multiplayer/Matchmaking/player-jump") }, + { InteractionSampleType.PlayerReJump, audio.Samples.Get(@"Multiplayer/Matchmaking/player-rejump") }, + { InteractionSampleType.OtherPlayerJump, audio.Samples.Get(@"Multiplayer/Matchmaking/player-jump-other") } + }; } protected override void LoadComplete() @@ -272,6 +295,9 @@ protected override void LoadComplete() avatar.ScaleTo(0) .ScaleTo(1, 500, Easing.OutElasticHalf) .FadeIn(200); + + // pick a random pitch to be used by the player for duration of this session + samplePitch = 0.75f + RNG.NextDouble(0f, 0.75f); } public PlayerPanelDisplayMode DisplayMode @@ -481,6 +507,11 @@ private void onMatchEvent(MatchServerEvent e) scale.Then().ScaleTo(new Vector2(1, 1.05f), 200, Easing.Out) .Then().ScaleTo(new Vector2(1, 0.95f), 200, Easing.In) .Then().ScaleTo(Vector2.One, 800, Easing.OutElastic); + + // only play jump sample if panel is visible + if (Alpha > 0) + playJumpSample(isConsecutive); + break; } @@ -490,6 +521,9 @@ private void onMatchEvent(MatchServerEvent e) private void onBeatmapAvailabilityChanged(MultiplayerRoomUser user, BeatmapAvailability availability) => Scheduler.Add(() => { + if (!user.Equals(RoomUser)) + return; + if (availability.State == DownloadState.Downloading) downloadProgressBar.FadeIn(200, Easing.OutPow10); else @@ -498,6 +532,44 @@ private void onBeatmapAvailabilityChanged(MultiplayerRoomUser user, BeatmapAvail downloadProgressBar.ResizeWidthTo(availability.DownloadProgress ?? 0, 200, Easing.OutPow10); }); + private void playJumpSample(bool rejumping) + { + bool isLocalUser = User.OnlineID == client.LocalUser?.UserID; + + if (isLocalUser) + playInteractionSample(rejumping ? InteractionSampleType.PlayerReJump : InteractionSampleType.PlayerJump); + else + playInteractionSample(InteractionSampleType.OtherPlayerJump); + } + + private void playInteractionSample(InteractionSampleType sampleType) + { + bool enoughTimePassedSinceLastPlayback = lastSamplePlayback == null || Time.Current - lastSamplePlayback.Value >= OsuGameBase.SAMPLE_DEBOUNCE_TIME; + if (!enoughTimePassedSinceLastPlayback) + return; + + Sample? targetSample = interactionSamples[sampleType]; + SampleChannel? targetChannel = interactionSampleChannels.GetValueOrDefault(sampleType); + + targetChannel?.Stop(); + targetChannel = targetSample?.GetChannel(); + + if (targetChannel == null) + return; + + float horizontalPos = BoundingBox.Centre.X / Parent!.ToLocalSpace(Parent!.ScreenSpaceDrawQuad).Width; + // rescale balance from 0..1 to -1..1 + float balance = -1f + horizontalPos * 2f; + + targetChannel.Frequency.Value = samplePitch; + targetChannel.Balance.Value = balance * OsuGameBase.SFX_STEREO_STRENGTH; + targetChannel.Play(); + + interactionSampleChannels[sampleType] = targetChannel; + + lastSamplePlayback = Time.Current; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs index 4b97400ebe15..ce14d0bb19c0 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs @@ -78,6 +78,7 @@ protected override void LoadComplete() client.MatchRoomStateChanged += onRoomStateChanged; client.UserJoined += onUserJoined; client.UserLeft += onUserLeft; + client.UserKicked += onUserLeft; if (client.Room != null) { @@ -207,6 +208,7 @@ protected override void Dispose(bool isDisposing) client.MatchRoomStateChanged -= onRoomStateChanged; client.UserJoined -= onUserJoined; client.UserLeft -= onUserLeft; + client.UserKicked -= onUserLeft; } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundResults/SubScreenRoundResults.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundResults/SubScreenRoundResults.cs index 580d157a8bad..882c58317f37 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundResults/SubScreenRoundResults.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundResults/SubScreenRoundResults.cs @@ -23,6 +23,7 @@ using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Screens.Ranking; +using osuTK; namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundResults { @@ -31,8 +32,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundResults /// public partial class SubScreenRoundResults : MatchmakingSubScreen { - private const int panel_spacing = 5; - public override PanelDisplayStyle PlayersDisplayStyle => PanelDisplayStyle.Hidden; public override Drawable? PlayersDisplayArea => null; @@ -51,7 +50,7 @@ public partial class SubScreenRoundResults : MatchmakingSubScreen [Resolved] private RulesetStore rulesets { get; set; } = null!; - private AutoScrollContainer scrollContainer = null!; + private PanelContainer panelContainer = null!; private LoadingSpinner loadingSpinner = null!; [BackgroundDependencyLoader] @@ -59,7 +58,7 @@ private void load() { InternalChildren = new Drawable[] { - scrollContainer = new AutoScrollContainer + panelContainer = new PanelContainer { RelativeSizeAxes = Axes.Both }, @@ -92,7 +91,7 @@ private async Task queryScores() var request = new IndexPlaylistScoresRequest(client.Room.RoomID, client.Room.Settings.PlaylistItemId); request.Success += req => scoreTask.SetResult(req.Scores); - request.Failure += e => scoreTask.SetException(e); + request.Failure += scoreTask.SetException; api.Queue(request); await Task.WhenAll(beatmapTask, scoreTask.Task).ConfigureAwait(false); @@ -136,78 +135,57 @@ private async Task queryScores() private void setScores(ScoreInfo[] scores) => Scheduler.Add(() => { - Container panels; - - scrollContainer.Child = panels = new Container + panelContainer.ChildrenEnumerable = scores.Select(s => new RoundResultsScorePanel(s) { - RelativeSizeAxes = Axes.Y, - Width = scores.Length * (ScorePanel.CONTRACTED_WIDTH + panel_spacing), - ChildrenEnumerable = scores.Select(s => new RoundResultsScorePanel(s) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }) - }; - - for (int i = 0; i < panels.Count; i++) - { - panels[i].MoveToX(panels.DrawWidth * 2) - .Delay(i * 100) - .MoveToX((ScorePanel.CONTRACTED_WIDTH + panel_spacing) * i, 500, Easing.OutQuint); - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); }); private partial class RoundResultsScorePanel : CompositeDrawable { public RoundResultsScorePanel(ScoreInfo score) { - AutoSizeAxes = Axes.Both; - InternalChild = new InstantSizingScorePanel(score); + Size = new Vector2(ScorePanel.CONTRACTED_WIDTH, ScorePanel.CONTRACTED_HEIGHT); + + InternalChild = new ScorePanel(score); } public override bool PropagateNonPositionalInputSubTree => false; public override bool PropagatePositionalInputSubTree => false; - - private partial class InstantSizingScorePanel : ScorePanel - { - public InstantSizingScorePanel(ScoreInfo score, bool isNewLocalScore = false) - : base(score, isNewLocalScore) - { - } - - protected override void LoadComplete() - { - base.LoadComplete(); - FinishTransforms(true); - } - } } - private partial class AutoScrollContainer : UserTrackingScrollContainer + private partial class PanelContainer : Container { - private const float initial_offset = -0.5f; - private const double scroll_duration = 20000; + protected override Container Content => flowContainer; - private double? scrollStartTime; + private readonly Container centreingContainer; + private readonly Container flowContainer; - public AutoScrollContainer() - : base(Direction.Horizontal) + public PanelContainer() { + InternalChild = new OsuScrollContainer(Direction.Horizontal) + { + RelativeSizeAxes = Axes.Both, + Child = centreingContainer = new Container + { + RelativeSizeAxes = Axes.Y, + Child = flowContainer = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Spacing = new Vector2(5) + } + } + }; } protected override void Update() { base.Update(); - - if (!UserScrolling && Children.Count > 0) - { - scrollStartTime ??= Time.Current; - - double scrollOffset = (Time.Current - scrollStartTime.Value) / scroll_duration; - - if (scrollOffset < 1) - ScrollTo(DrawWidth * (initial_offset + scrollOffset), false); - } + centreingContainer.Width = Math.Max(DrawWidth, flowContainer.DrawWidth); } } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.HistoryFooterButton.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.HistoryFooterButton.cs new file mode 100644 index 000000000000..f46c0611c5a8 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.HistoryFooterButton.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Footer; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + public partial class ScreenMatchmaking + { + private partial class HistoryFooterButton : ScreenFooterButton + { + [Resolved] + private OsuGame? game { get; set; } + + private readonly MultiplayerRoom room; + + public HistoryFooterButton(MultiplayerRoom room) + { + this.room = room; + + Action = openRoomHistory; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Text = "History"; + Icon = FontAwesome.Solid.Globe; + AccentColour = colours.Lime1; + } + + private void openRoomHistory() + => game?.OpenUrlExternally($@"/multiplayer/rooms/{room.RoomID}/events"); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.ScreenStack.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.ScreenStack.cs index 279dd98a5efe..55bbcf7ce57c 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.ScreenStack.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.ScreenStack.cs @@ -36,10 +36,7 @@ private void load() new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(6) - { - Bottom = StageDisplay.HEIGHT + 6, - }, + Padding = new MarginPadding { Top = StageDisplay.HEIGHT, Bottom = 6 }, Children = new Drawable[] { screenStack = new Framework.Screens.ScreenStack(), @@ -51,8 +48,6 @@ private void load() }, new StageDisplay { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.X } }; @@ -108,7 +103,7 @@ private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() case MatchmakingStage.ServerBeatmapFinalised: Debug.Assert(screenStack.CurrentScreen is SubScreenBeatmapSelect); - ((SubScreenBeatmapSelect)screenStack.CurrentScreen).RollFinalBeatmap(matchmakingState.CandidateItems, matchmakingState.CandidateItem); + ((SubScreenBeatmapSelect)screenStack.CurrentScreen).RollFinalBeatmap(matchmakingState.CandidateItems, matchmakingState.CandidateItem, matchmakingState.GameplayItem); break; case MatchmakingStage.ResultsDisplaying: diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs index 527b1ba243e4..0c0c1006adfa 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using System.Threading; using osu.Framework.Allocation; @@ -12,7 +13,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Screens; @@ -29,6 +29,8 @@ using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; +using osu.Game.Screens.Footer; +using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Gameplay; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Users; @@ -47,12 +49,19 @@ public partial class ScreenMatchmaking : OsuScreen, IPreviewTrackOwner, IHandleP /// private const float row_padding = 10; + private static readonly Vector2 chat_size = new Vector2(550, 130); + public override bool? ApplyModTrackAdjustments => true; public override bool DisallowExternalBeatmapRulesetChanges => true; public override bool ShowFooter => true; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + protected override BackgroundScreen CreateBackground() => new MatchmakingBackgroundScreen(colourProvider); + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker(); @@ -104,14 +113,18 @@ public ScreenMatchmaking(MultiplayerRoom room) { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Size = new Vector2(700, 130), - Margin = new MarginPadding { Bottom = 10, Right = WaveOverlayContainer.WIDTH_PADDING - HORIZONTAL_OVERFLOW_PADDING }, + Size = chat_size, + Margin = new MarginPadding + { + Right = WaveOverlayContainer.WIDTH_PADDING - HORIZONTAL_OVERFLOW_PADDING, + Bottom = row_padding + }, Alpha = 0 }; } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load() { sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); @@ -125,49 +138,44 @@ private void load(OverlayColourProvider colourProvider) { beatmapAvailabilityTracker, new MultiplayerRoomSounds(), - new GridContainer + new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { - Horizontal = WaveOverlayContainer.WIDTH_PADDING, - Top = row_padding, + Horizontal = HORIZONTAL_OVERFLOW_PADDING, }, - RowDimensions = new[] + Child = new InverseScalingDrawSizePreservingFillContainer { - new Dimension(), - new Dimension(GridSizeMode.Absolute, row_padding), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new Drawable[]?[] - { - [ - new Container + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 10, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background6, - }, - new ScreenStack(), - } - } - ], - null, - [ - new Container + new Dimension(), + new Dimension(GridSizeMode.Absolute, row_padding), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new Drawable[]?[] { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Size = new Vector2(700, 130), - Margin = new MarginPadding { Bottom = row_padding } + [ + new ScreenStack(), + ], + null, + [ + new Container + { + Name = "Chat Area Space", + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Size = new Vector2(550, 130), + Margin = new MarginPadding { Bottom = row_padding } + } + ] } - ] + } } } } @@ -194,6 +202,7 @@ private void onRoomUpdated() if (this.IsCurrentScreen() && client.Room == null) { Logger.Log($"{this} exiting due to loss of room or connection"); + exitConfirmed = true; this.Exit(); } } @@ -238,7 +247,7 @@ private void updateGameplayState() // Update global gameplay state to correspond to the new selection. // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info - var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID); + var localBeatmap = beatmapManager.QueryOnlineBeatmapId(item.BeatmapID); if (localBeatmap != null) { @@ -326,6 +335,11 @@ protected override bool OnKeyDown(KeyDownEvent e) return false; } + public override IReadOnlyList CreateFooterButtons() => + [ + new HistoryFooterButton(room) + ]; + public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); @@ -365,7 +379,7 @@ public override bool OnExiting(ScreenExitEvent e) confirmDialog.PerformOkAction(); else { - dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + dialogOverlay.Push(new ConfirmExitMultiplayerMatchDialog(() => { exitConfirmed = true; if (this.IsCurrentScreen()) @@ -463,12 +477,17 @@ public ChatContainer(MatchmakingChatDisplay chat) // This component is added to the screen footer which is only about 50px high. // Therefore, it's given a large absolute size to give the context menu enough space to display correctly. - Size = new Vector2(700); + Size = new Vector2(chat_size.X); InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Child = chat + Child = new InverseScalingDrawSizePreservingFillContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = chat + } }; } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StageSegment.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StageSegment.cs index 7e3b7d446867..50806e6b270c 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StageSegment.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StageSegment.cs @@ -7,7 +7,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; @@ -92,11 +91,7 @@ private void load(AudioManager audio, OverlayColourProvider colourProvider) new Box { RelativeSizeAxes = Axes.Both, - Colour = - ColourInfo.GradientVertical( - colourProvider.Dark2, - colourProvider.Dark1 - ), + Colour = colourProvider.Dark3, }, progressBar = new Box { @@ -104,7 +99,7 @@ private void load(AudioManager audio, OverlayColourProvider colourProvider) EdgeSmoothness = new Vector2(1), RelativeSizeAxes = Axes.Both, Width = 0, - Colour = colourProvider.Dark3, + Colour = colourProvider.Colour3, }, new OsuSpriteText { diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.cs index b45e8054a020..53cdc6d85e97 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.cs @@ -9,7 +9,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -17,6 +19,7 @@ using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; using osu.Game.Overlays; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match { @@ -31,7 +34,7 @@ public partial class StageDisplay : CompositeDrawable private const int round_count = 5; private OsuScrollContainer scroll = null!; - private FillFlowContainer flow = null!; + private FillFlowContainer flow = null!; private CurrentRoundDisplay roundDisplay = null!; @@ -46,10 +49,16 @@ private void load(OverlayColourProvider colourProvider) { InternalChildren = new Drawable[] { - new Box + new BufferedContainer(cachedFrameBuffer: true) { - Colour = colourProvider.Dark6, RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4.White, Color4.Transparent), + Alpha = 0.8f, + Child = new Box + { + Colour = ColourInfo.GradientHorizontal(colourProvider.Dark6, colourProvider.Dark6.Opacity(0.5f)), + RelativeSizeAxes = Axes.Both, + } }, new Container { @@ -63,7 +72,7 @@ private void load(OverlayColourProvider colourProvider) ClampExtension = 0, RelativeSizeAxes = Axes.X, Height = HEIGHT, - Child = flow = new FillFlowContainer + Child = flow = new FillFlowContainer { Padding = new MarginPadding { Horizontal = 2000 }, AutoSizeAxes = Axes.Both, @@ -84,15 +93,6 @@ private void load(OverlayColourProvider colourProvider) Anchor = Anchor.Centre, Origin = Anchor.Centre }, - new Box - { - Colour = ColourInfo.GradientHorizontal( - colourProvider.Dark4, - colourProvider.Dark5.Opacity(0) - ), - RelativeSizeAxes = Axes.Y, - Width = 240, - }, roundDisplay = new CurrentRoundDisplay { X = 12, @@ -119,7 +119,7 @@ private void load(OverlayColourProvider colourProvider) protected override void Update() { base.Update(); - var bubble = flow.OfType().FirstOrDefault(b => b.Active); + var bubble = flow.FirstOrDefault(b => b.Active); if (bubble != null) { @@ -128,6 +128,21 @@ protected override void Update() } } + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + foreach (var segment in flow) + { + if (segment.Active) + return; + + float offset = segment.ToSpaceOfOtherDrawable(Vector2.Zero, this).X; + + segment.Alpha = float.Clamp(offset / 300, 0.1f, 0.5f); + } + } + private partial class StageScrollContainer : OsuScrollContainer { public override bool HandlePositionalInput => false; @@ -158,47 +173,55 @@ private void load(OverlayColourProvider colours, AudioManager audio) { Size = new Vector2(76); + progress = new CircularProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colours.Colour2, + InnerRadius = 0.1f, + RelativeSizeAxes = Axes.Both, + RoundedCaps = true, + }; InternalChildren = new Drawable[] { new Circle { - Colour = ColourInfo.GradientVertical( - colours.Dark2, - colours.Dark4 - ), + Colour = colours.Dark4, RelativeSizeAxes = Axes.Both, }, - progress = new CircularProgress + (progress = new CircularProgress { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Colour = ColourInfo.GradientVertical( - colours.Light1, - colours.Dark2 - ), + Colour = colours.Colour2, InnerRadius = 0.1f, - RelativeSizeAxes = Axes.Both, - }, + Size = Size, + }).WithEffect(new GlowEffect + { + Colour = colours.Colour2, + BlurSigma = new Vector2(10), + Strength = 2f, + Placement = EffectPlacement.Behind, + PadExtent = true, + }), innerCircle = new Circle { Alpha = 0.2f, Blending = BlendingParameters.Additive, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Colour = ColourInfo.GradientVertical( - colours.Dark1, - colours.Dark2 - ), + Colour = colours.Dark1, Scale = new Vector2(0.9f), RelativeSizeAxes = Axes.Both, }, new OsuSpriteText { - Y = 10, + Y = 13, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Font = OsuFont.Style.Caption2, Text = "Round", + Colour = colours.Content2 }, text = new OsuSpriteText { @@ -245,10 +268,10 @@ public int? Round round = value.Value; this.ScaleTo(6, 1000, Easing.OutPow10) - .MoveToY(-300, 1000, Easing.OutPow10) + .MoveToY(300, 1000, Easing.OutPow10) .Then() - .MoveToY(0, 500, Easing.InQuart) - .ScaleTo(1, 500, Easing.InQuart); + .MoveToY(0, 500, new CubicBezierEasingFunction(0.8, 0, 0.6, 1)) + .ScaleTo(1, 500, new CubicBezierEasingFunction(0.8, 0, 0.6, 1)); swishChannel = swishSample?.GetChannel(); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs index 1e6dd0f23185..7995f72f1af6 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs @@ -24,7 +24,7 @@ public partial class PoolSelector : CompositeDrawable { private const float icon_size = 34; - public readonly Bindable AvailablePools = new Bindable(); + public readonly Bindable AvailablePools = new Bindable([]); public readonly Bindable SelectedPool = new Bindable(); private FillFlowContainer poolFlow = null!; @@ -92,7 +92,7 @@ protected override bool OnKeyDown(KeyDownEvent e) private partial class SelectorButton : OsuAnimatedButton { - public static readonly Vector2 SIZE = new Vector2(84, 64); + public static readonly Vector2 SIZE = new Vector2(84, 78); public bool IsSelected => SelectedPool.Value?.Equals(pool) == true; @@ -106,8 +106,6 @@ private partial class SelectorButton : OsuAnimatedButton private Box flashLayer = null!; - private OsuSpriteText text = null!; - public SelectorButton(MatchmakingPool pool) : base(HoverSampleSet.ButtonSidebar) { @@ -123,6 +121,12 @@ private void load(OverlayColourProvider colourProvider) Content.CornerRadius = 16; Content.CornerExponent = 10; + Ruleset? rulesetInstance = rulesetStore.GetRuleset(pool.RulesetId)?.CreateInstance(); + + string rulesetName = rulesetInstance?.Description ?? string.Empty; + if (pool.Variant != 0) + rulesetName += $" {pool.Variant}K"; + Children = new Drawable[] { new Box @@ -156,13 +160,28 @@ private void load(OverlayColourProvider colourProvider) iconSprite = createIcon(), } }, - text = new OsuSpriteText + new FillFlowContainer { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Font = OsuFont.Style.Caption2, - Text = pool.Name, - }, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + Text = rulesetName, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Style.Caption2, + Text = pool.Name + } + } + } } }, }; @@ -198,14 +217,12 @@ private void onSelectionChanged(ValueChangedEvent selection) { this.ScaleTo(1.2f, 200, Easing.OutQuint); iconSprite.FadeColour(Color4.Gold, 100, Easing.OutQuint); - text.Font = text.Font.With(weight: FontWeight.Bold); flashLayer.FadeTo(0.1f, 200, Easing.OutQuint); } else { this.ScaleTo(1f, 200, Easing.OutQuint); iconSprite.FadeColour(OsuColour.Gray(0.5f), 100); - text.Font = text.Font.With(weight: FontWeight.Regular); flashLayer.FadeOut(200, Easing.OutQuint); } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index f72f26f26e28..02e944df9f93 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.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.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -11,6 +12,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; using osu.Game.Graphics; +using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -39,6 +41,7 @@ public partial class QueueController : Component private BackgroundQueueNotification? backgroundNotification; private bool isBackgrounded; + private MatchmakingPool? lastJoinedPool; protected override void LoadComplete() { @@ -51,6 +54,36 @@ protected override void LoadComplete() client.MatchmakingRoomReady += onMatchmakingRoomReady; } + /// + /// Joins the matchmaking queue. + /// + /// The pool to join. + public void JoinQueue(MatchmakingPool pool) + { + client.MatchmakingJoinQueue(pool.Id).FireAndForget(); + lastJoinedPool = pool; + } + + /// + /// Leaves the matchmaking queue. + /// + public void LeaveQueue() + { + client.MatchmakingLeaveQueue().FireAndForget(); + } + + /// + /// Rejoins the last joined matchmaking queue. + /// + public void RejoinQueue() + { + if (lastJoinedPool != null) + JoinQueue(lastJoinedPool); + } + + /// + /// Moves the matchmaking queue search to the background. + /// public void SearchInBackground() { if (isBackgrounded) @@ -60,6 +93,9 @@ public void SearchInBackground() postNotification(); } + /// + /// Moves the matchmaking queue search to the foreground. + /// public void SearchInForeground() { if (!isBackgrounded) @@ -94,15 +130,12 @@ private void onMatchmakingQueueLeft() => Scheduler.Add(() => closeNotifications(); }); - private void onMatchmakingRoomInvited() => Scheduler.Add(() => + private void onMatchmakingRoomInvited(MatchmakingRoomInvitationParams invitation) => Scheduler.Add(() => { CurrentState.Value = ScreenQueue.MatchmakingScreenState.PendingAccept; - if (backgroundNotification != null) - { - backgroundNotification.State = ProgressNotificationState.Completed; - backgroundNotification = null; - } + backgroundNotification?.Complete(invitation); + backgroundNotification = null; }); private void onMatchmakingRoomReady(long roomId, string password) => Scheduler.Add(() => @@ -119,7 +152,8 @@ private void postNotification() if (backgroundNotification != null) return; - notifications?.Post(backgroundNotification = new BackgroundQueueNotification(this)); + Debug.Assert(lastJoinedPool != null); + notifications?.Post(backgroundNotification = new BackgroundQueueNotification(this, lastJoinedPool.Type)); } private void closeNotifications() @@ -155,13 +189,15 @@ private partial class BackgroundQueueNotification : ProgressNotification private MultiplayerClient client { get; set; } = null!; private readonly QueueController controller; + private readonly MatchmakingPoolType poolType; private Notification? foundNotification; private Sample? matchFoundSample; - public BackgroundQueueNotification(QueueController controller) + public BackgroundQueueNotification(QueueController controller, MatchmakingPoolType poolType) { this.controller = controller; + this.poolType = poolType; } [BackgroundDependencyLoader] @@ -169,15 +205,18 @@ private void load(AudioManager audio) { Text = "Searching for opponents..."; - CompletionClickAction = () => + Activated = () => { - client.MatchmakingAcceptInvitation().FireAndForget(); - controller.CurrentState.Value = ScreenQueue.MatchmakingScreenState.AcceptedWaitingForRoom; + performer?.PerformFromScreen(s => + { + if (s is ScreenIntro || s is ScreenQueue) + return; - performer?.PerformFromScreen(s => s.Push(new ScreenIntro())); + s.Push(new ScreenIntro(poolType)); + }, [typeof(ScreenIntro), typeof(ScreenQueue)]); - Close(false); - return true; + // Closed when appropriate by SearchInForeground(). + return false; }; CancelRequested = () => @@ -189,6 +228,22 @@ private void load(AudioManager audio) matchFoundSample = audio.Samples.Get(@"Multiplayer/Matchmaking/match-found"); } + public void Complete(MatchmakingRoomInvitationParams invitation) + { + CompletionClickAction = () => + { + client.MatchmakingAcceptInvitation().FireAndForget(); + controller.CurrentState.Value = ScreenQueue.MatchmakingScreenState.AcceptedWaitingForRoom; + + performer?.PerformFromScreen(s => s.Push(new ScreenIntro(invitation.Type))); + + Close(false); + return true; + }; + + State = ProgressNotificationState.Completed; + } + protected override Notification CreateCompletionNotification() { // Playing here means it will play even if notification overlay is hidden. diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs index 8eaa28079403..2d28cafaab5c 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs @@ -29,9 +29,10 @@ using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer; using osu.Game.Overlays; -using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; using osuTK; namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue @@ -43,9 +44,10 @@ public partial class ScreenQueue : OsuScreen { public override bool ShowFooter => true; + public override bool? ApplyModTrackAdjustments => false; + private Container mainContent = null!; - private MatchmakingScreenState state; private CloudVisualisation cloud = null!; [Resolved] @@ -60,9 +62,6 @@ public partial class ScreenQueue : OsuScreen [Resolved] private MultiplayerClient client { get; set; } = null!; - [Resolved] - private IDialogOverlay dialogOverlay { get; set; } = null!; - [Resolved] private QueueController controller { get; set; } = null!; @@ -77,9 +76,11 @@ public partial class ScreenQueue : OsuScreen private readonly IBindable currentState = new Bindable(); - private readonly Bindable availablePools = new Bindable(); + private readonly Bindable availablePools = new Bindable([]); private readonly Bindable selectedPool = new Bindable(); + private readonly MatchmakingPoolType poolType; + private CancellationTokenSource userLookupCancellation = new CancellationTokenSource(); private Sample? enqueueSample; @@ -89,55 +90,65 @@ public partial class ScreenQueue : OsuScreen private SampleChannel? waitingLoopChannel; private ScheduledDelegate? startLoopPlaybackDelegate; + public ScreenQueue(MatchmakingPoolType poolType) + { + this.poolType = poolType; + } + protected override void LoadComplete() { base.LoadComplete(); - InternalChildren = new Drawable[] + InternalChild = new InverseScalingDrawSizePreservingFillContainer { - cloud = new CloudVisualisation - { - Y = -100, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.6f) - }, - new MatchmakingAvatar(api.LocalUser.Value, true) - { - Y = -100, - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new Container + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativePositionAxes = Axes.Y, - Y = 0.25f, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - CornerRadius = 10f, - Masking = true, - Children = new Drawable[] + new GlobalScrollAdjustsVolume(), + cloud = new CloudVisualisation { - new Box - { - Colour = colourProvider.Background3, - RelativeSizeAxes = Axes.Both, - }, - mainContent = new Container + Y = -100, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.6f) + }, + new MatchmakingAvatar(api.LocalUser.Value, true) + { + Y = -100, + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new Container + { + RelativePositionAxes = Axes.Y, + Y = 0.25f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + CornerRadius = 10f, + Masking = true, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Alpha = 0, - AutoSizeAxes = Axes.Both, - AutoSizeDuration = 300, - AutoSizeEasing = Easing.OutQuint, - Padding = new MarginPadding(20), - }, - } - }, + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + mainContent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 300, + AutoSizeEasing = Easing.OutQuint, + Padding = new MarginPadding(20), + }, + } + }, + } }; currentState.BindTo(controller.CurrentState); @@ -150,7 +161,7 @@ protected override void LoadComplete() private async Task populateAvailablePools() { - MatchmakingPool[] pools = await client.GetMatchmakingPools().ConfigureAwait(false); + MatchmakingPool[] pools = await client.GetMatchmakingPoolsOfType(poolType).ConfigureAwait(false); Schedule(() => { @@ -209,9 +220,6 @@ public override void OnSuspending(ScreenTransitionEvent e) client.MatchmakingLeaveLobby().FireAndForget(); } - private bool exitConfirmed; - private bool isBackgrounded; - public override bool OnExiting(ScreenExitEvent e) { if (base.OnExiting(e)) @@ -219,31 +227,24 @@ public override bool OnExiting(ScreenExitEvent e) client.MatchmakingLeaveLobby().FireAndForget(); - if (isBackgrounded) - return false; - - if (exitConfirmed) + switch (currentState.Value) { - client.MatchmakingLeaveQueue().FireAndForget(); - return false; - } + default: + return false; - if (currentState.Value == MatchmakingScreenState.Idle) - return false; + case MatchmakingScreenState.Queueing: + controller.SearchInBackground(); + return false; - if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) - confirmDialog.PerformOkAction(); - else - { - dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave the matchmaking queue?", () => - { - exitConfirmed = true; - if (this.IsCurrentScreen()) - this.Exit(); - })); - } + case MatchmakingScreenState.PendingAccept: + case MatchmakingScreenState.AcceptedWaitingForRoom: + controller.LeaveQueue(); + return true; - return true; + case MatchmakingScreenState.InRoom: + // Block exit until it's initiated from inside the matchmaking screen. + return true; + } } public APIUser[] Users @@ -253,8 +254,6 @@ public APIUser[] Users public void SetState(MatchmakingScreenState newState) { - state = newState; - mainContent.FadeInFromZero(500, Easing.OutQuint); mainContent.Clear(); @@ -280,17 +279,18 @@ public void SetState(MatchmakingScreenState newState) AvailablePools = { BindTarget = availablePools }, SelectedPool = { BindTarget = selectedPool } }, - new BeginQueueingButton(200) + new BeginQueueingButton { DarkerColour = colours.Blue2, LighterColour = colours.Blue1, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, + Width = 200, SelectedPool = { BindTarget = selectedPool }, Action = () => { Debug.Assert(selectedPool.Value != null); - client.MatchmakingJoinQueue(selectedPool.Value.Id).FireAndForget(); + controller.JoinQueue(selectedPool.Value); }, Text = "Begin queueing", } @@ -299,8 +299,6 @@ public void SetState(MatchmakingScreenState newState) break; case MatchmakingScreenState.Queueing: - ShearedButton sendToBackgroundButton; - mainContent.Child = new FillFlowContainer { Anchor = Anchor.Centre, @@ -321,70 +319,27 @@ public void SetState(MatchmakingScreenState newState) { State = { Value = Visibility.Visible }, }, - sendToBackgroundButton = new ShearedButton(200) + new ShearedButton { - DarkerColour = colours.Orange3, - LighterColour = colours.Orange4, + DarkerColour = colours.Red3, + LighterColour = colours.Red4, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Queue in background", - Action = () => - { - controller.SearchInBackground(); - isBackgrounded = true; - this.Exit(); - }, - Enabled = { Value = false }, - TooltipText = "Wait 5 seconds for this option to become available." + Width = 200, + Text = "Stop queueing", + Action = () => controller.LeaveQueue() } } }; - Scheduler.AddDelayed(() => - { - if (state != newState) - return; - - sendToBackgroundButton.Enabled.Value = true; - sendToBackgroundButton.TooltipText = "You will receive a notification when your game is ready. Make sure to watch out for it!"; - }, 5000); - enqueueSample?.Play(); startLoopPlaybackDelegate = Scheduler.AddDelayed(startWaitingLoopPlayback, 2000); break; case MatchmakingScreenState.PendingAccept: - mainContent.Child = new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(20), - Children = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "Found a match!", - Font = OsuFont.GetFont(size: 32, weight: FontWeight.Regular, typeface: Typeface.TorusAlternate), - }, - new SelectionButton(200) - { - DarkerColour = colours.YellowDark, - LighterColour = colours.YellowLight, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Action = () => - { - client.MatchmakingAcceptInvitation().FireAndForget(); - SetState(MatchmakingScreenState.AcceptedWaitingForRoom); - }, - Text = "Join match!", - } - } - }; + client.MatchmakingAcceptInvitation().FireAndForget(); + SetState(MatchmakingScreenState.AcceptedWaitingForRoom); + matchFoundSample?.Play(); musicController.DuckMomentarily(1250); break; @@ -403,7 +358,7 @@ public void SetState(MatchmakingScreenState newState) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Waiting for all players...", + Text = "Waiting for opponents...", Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), }, new LoadingSpinner @@ -438,7 +393,22 @@ public void SetState(MatchmakingScreenState newState) }; using (BeginDelayedSequence(2000)) - Schedule(() => this.Push(new ScreenMatchmaking(client.Room!))); + { + Schedule(() => + { + switch (poolType) + { + case MatchmakingPoolType.QuickPlay: + this.Push(new ScreenMatchmaking(client.Room!)); + break; + + case MatchmakingPoolType.RankedPlay: + this.Push(new RankedPlayScreen(client.Room!)); + break; + } + }); + } + break; default: @@ -487,11 +457,6 @@ private partial class BeginQueueingButton : SelectionButton { public readonly IBindable SelectedPool = new Bindable(); - public BeginQueueingButton(float? width = null) - : base(width) - { - } - protected override void LoadComplete() { base.LoadComplete(); @@ -502,11 +467,6 @@ protected override void LoadComplete() private partial class SelectionButton : ShearedButton, IKeyBindingHandler { - public SelectionButton(float? width = null, float height = DEFAULT_HEIGHT) - : base(width, height) - { - } - public bool OnPressed(KeyBindingPressEvent e) { if (e.Action == GlobalAction.Select && !e.Repeat) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.UserTags.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.UserTags.cs new file mode 100644 index 000000000000..decbccc6bf51 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.UserTags.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Ranking; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class CardDetailsOverlayContainer + { + private partial class UserTagSection : CompositeDrawable + { + public IEnumerable Tags + { + set + { + Debug.Assert(LoadState >= LoadState.Ready); + + tagFlow.ChildrenEnumerable = value.Select(tag => new DrawableUserTag(tag)); + this.FadeTo(tagFlow.Children.Count > 0 ? 1 : 0); + } + } + + private FillFlowContainer tagFlow = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding(10); + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = + [ + new OsuSpriteText + { + Text = "User Tags", + Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold), + }, + tagFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(4) + } + ] + }; + } + } + + private partial class DrawableUserTag(UserTag tag) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 3; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = + [ + new Container + { + AutoSizeAxes = Axes.Both, + Alpha = tag.GroupName != null ? 1 : 0, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour.Gray6, + }, + new OsuSpriteText + { + Text = tag.GroupName ?? "", + Padding = new MarginPadding { Left = 5, Right = 3 }, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + } + ] + }, + new Container + { + AutoSizeAxes = Axes.Both, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour.Gray2, + }, + new OsuSpriteText + { + Text = tag.DisplayName, + Padding = new MarginPadding { Left = 5, Right = 3 }, + Font = OsuFont.GetFont(size: 12), + } + ] + }, + ] + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.cs new file mode 100644 index 000000000000..21b42b981ee3 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/CardDetailsOverlayContainer.cs @@ -0,0 +1,146 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Threading; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Screens.Ranking; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + [Cached] + public partial class CardDetailsOverlayContainer : Container + { + public double HideDelay { get; set; } = 1000; + + protected override Container Content { get; } + + private readonly CardDetailsOverlay overlay; + + public CardDetailsOverlayContainer() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = + [ + Content = new Container + { + RelativeSizeAxes = Axes.Both, + }, + overlay = new CardDetailsOverlay + { + Alpha = 0, + } + ]; + } + + private ScheduledDelegate? hideDelegate; + + public void ShowCardDetails(Drawable targetDrawable, APIBeatmap beatmap) + { + // TODO: remove this once there's more than just tags in the overlay + if (beatmap.GetTopUserTags().Length == 0) + return; + + hideDelegate?.Cancel(); + hideDelegate = Scheduler.AddDelayed(overlay.Hide, HideDelay); + + overlay.TargetDrawable = targetDrawable; + overlay.Beatmap.Value = beatmap; + overlay.Show(); + } + + private partial class CardDetailsOverlay : VisibilityContainer + { + public readonly Bindable Beatmap = new Bindable(); + + public Drawable? TargetDrawable; + + private Container content = null!; + private UserTagSection tagSection = null!; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Width = 200; + Origin = Anchor.CentreRight; + + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 6, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + Alpha = 0.85f, + }, + tagSection = new UserTagSection() + ] + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Beatmap.BindValueChanged(e => + { + if (e.NewValue != null) + populateContent(e.NewValue); + }, true); + } + + private void populateContent(APIBeatmap beatmap) + { + tagSection.Tags = beatmap.GetTopUserTags().Select(it => new UserTag(it.Tag) { VoteCount = { Value = it.VoteCount } }); + } + + private Vector2 targetPosition => TargetDrawable is { } drawable + ? Parent!.ToLocalSpace(drawable.ScreenSpaceDrawQuad.TopLeft) + new Vector2(-20, 0) + // this results essentially a no-op when there's no valid target + : Position; + + private readonly Vector2Spring position = new Vector2Spring + { + NaturalFrequency = 2f, + Response = 0.25f, + Damping = 0.85f + }; + + protected override void Update() + { + base.Update(); + + // Workaround for AutoSizeAxes not working due to content being able to move + Height = content.Height; + + Position = position.Update(Time.Elapsed, targetPosition); + } + + protected override void PopIn() + { + this.FadeIn(300); + + content.MoveToX(-50) + .MoveToX(0, 400, Easing.OutExpo); + + position.Current = position.PreviousTarget = targetPosition; + } + + protected override void PopOut() => this.FadeOut(300); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs new file mode 100644 index 000000000000..b1350a95715f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.SongPreview.cs @@ -0,0 +1,307 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Input.Events; +using osu.Framework.Timing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCard + { + public partial class SongPreviewContainer : Container, IBeatSyncProvider + { + private const double minimum_beat_length = 800; + + public readonly Bindable Enabled = new BindableBool(true); + + public bool TrackLoaded => previewTrack?.TrackLoaded ?? false; + + public bool IsRunning => previewTrack?.IsRunning ?? false; + + protected override Container Content { get; } + + private readonly Bindable trackRunning = new BindableBool(); + private readonly Container overlayLayer; + + private bool shouldBePlaying => Enabled.Value && IsHovered; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private OsuColour osuColour { get; set; } = null!; + + public SongPreviewContainer() + { + InternalChildren = + [ + new PulseContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + Content = new Container + { + RelativeSizeAxes = Axes.Both, + }, + overlayLayer = new Container + { + RelativeSizeAxes = Axes.Both, + } + ] + }, + ]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Enabled.BindValueChanged(enabled => + { + if (!enabled.NewValue) + { + previewTrack?.Stop(); + return; + } + + if (shouldBePlaying) + { + startPreviewIfAvailable(); + } + }); + } + + private PreviewTrack? previewTrack; + + public void LoadPreview(APIBeatmap beatmap) + { + Debug.Assert(previewTrack == null); + + LoadComponentAsync(previewTrack = previewTrackManager.Get(beatmap.BeatmapSet!), track => + { + AddInternal(track); + + track.Looping = true; + track.Started += onTrackStarted; + track.Stopped += onTrackStopped; + + setupBeatSyncProvider(track, beatmap); + + var cardColours = new RankedPlayCardContent.CardColours(beatmap, osuColour); + + overlayLayer.Add(new RippleVisualization(cardColours.Border) + { + TrackRunning = trackRunning.GetBoundCopy(), + }); + + if (IsHovered) + startPreviewIfAvailable(); + }); + } + + protected override bool OnHover(HoverEvent e) + { + if (shouldBePlaying) + startPreviewIfAvailable(); + + return base.OnHover(e); + } + + private void onTrackStarted() => Schedule(() => trackRunning.Value = true); + + private void onTrackStopped() => Schedule(() => trackRunning.Value = false); + + private void startPreviewIfAvailable() => previewTrack?.Start(); + + #region IBeatSyncProvider implementation + + private readonly PreviewTrackClock beatSyncClock = new PreviewTrackClock(); + private readonly ControlPointInfo controlPoints = new ControlPointInfo(); + + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => ChannelAmplitudes.Empty; + ControlPointInfo IBeatSyncProvider.ControlPoints => controlPoints; + IClock IBeatSyncProvider.Clock => beatSyncClock; + + private void setupBeatSyncProvider(PreviewTrack track, APIBeatmap beatmap) + { + beatSyncClock.Track = track; + + controlPoints.Add(0, new TimingControlPoint + { + BeatLength = beatmap.BPM > 0 ? 60_000 / beatmap.BPM : TimingControlPoint.DEFAULT_BEAT_LENGTH + }); + } + + private class PreviewTrackClock : IClock + { + public PreviewTrack? Track { get; set; } + + public double CurrentTime => Track?.CurrentTime ?? 0; + public double Rate => 1; + public bool IsRunning => Track?.IsRunning ?? false; + } + + #endregion + + private partial class PulseContainer : BeatSyncedContainer + { + public const double EXPAND_DURATION = 200; + + public PulseContainer() + { + MinimumBeatLength = minimum_beat_length; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!IsBeatSyncedWithTrack) + return; + + double beatLength = TimeUntilNextBeat; + + this.ScaleTo(1.02f, EXPAND_DURATION, Easing.In) + .Then() + .ScaleTo(1f, Math.Max(0, beatLength - EXPAND_DURATION), new CubicBezierEasingFunction(easeIn: 0.1f, easeOut: 1f)); + } + } + + private partial class RippleVisualization : BeatSyncedContainer + { + [Resolved] + private SongPreviewParticleContainer? particleContainer { get; set; } + + public required IBindable TrackRunning { get; init; } + + private readonly Color4 accentColour; + private readonly Container rippleContainer; + + public RippleVisualization(Color4 accentColour) + { + this.accentColour = accentColour; + + MinimumBeatLength = minimum_beat_length; + + RelativeSizeAxes = Axes.Both; + + InternalChildren = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = CORNER_RADIUS + 1.5f, + Blending = BlendingParameters.Additive, + BorderThickness = 2f, + BorderColour = this.accentColour.Opacity(0.5f), + EdgeEffect = new EdgeEffectParameters + { + Colour = this.accentColour.Opacity(0.1f), + Type = EdgeEffectType.Glow, + Radius = 25f, + Hollow = true, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + EdgeSmoothness = new Vector2(3), + }, + }, + rippleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + } + ]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TrackRunning.BindValueChanged(e => + { + if (e.NewValue) + { + rippleContainer.Clear(); + this.FadeIn(100); + } + else + { + this.FadeOut(200); + } + }, true); + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!IsBeatSyncedWithTrack) + return; + + var ripple = new Container + { + Size = DrawSize, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = CORNER_RADIUS, + BorderThickness = 2, + BorderColour = accentColour, + Blending = BlendingParameters.Additive, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + Alpha = 0, + }; + + rippleContainer.Add(ripple); + + const float expansion = 20; + + // The animation here is delayed to be in sync with the pulse-container's expansion animation. + // Since the pulse container expands with ease-out, the animation starts a tiny bit + // earlier, so it looks like it's maintaining the momentum of the pulse container's expansion + using (BeginDelayedSequence(PulseContainer.EXPAND_DURATION - 50)) + { + ripple.FadeIn(200) + .Then() + .FadeOut(1000); + + ripple.ResizeTo(DrawSize + new Vector2(expansion), 1000, Easing.OutQuart) + .TransformTo(nameof(CornerRadius), CORNER_RADIUS + expansion / 2, 1000, Easing.OutQuart) + .TransformTo(nameof(BorderThickness), 0.5f, 1000, Easing.In) + .Expire(); + + Schedule(() => particleContainer?.AddParticles(this, accentColour)); + } + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.cs new file mode 100644 index 000000000000..8ece23e4d33b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCard.cs @@ -0,0 +1,241 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Game.Audio; +using osu.Game.Database; +using osu.Game.Online.Rooms; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + [Cached] + public partial class RankedPlayCard : CompositeDrawable + { + public static readonly Vector2 SIZE = new Vector2(120, 200); + + public static readonly float CORNER_RADIUS = 6; + + public readonly RankedPlayCardWithPlaylistItem Item; + + private readonly IBindable playlistItem; + + public readonly Bindable SongPreviewEnabled = new BindableBool(true); + + private readonly Container content; + private readonly Container cardContent; + private readonly Container shadow; + private readonly SelectionOutline selectionOutline; + private readonly SongPreviewContainer songPreviewContainer; + + public bool ShowSelectionOutline + { + set => selectionOutline.FadeTo(value ? 1 : 0, 50); + } + + public float Elevation; + + public bool PreviewTrackLoaded => songPreviewContainer.TrackLoaded; + public bool PreviewTrackRunning => songPreviewContainer.IsRunning; + + private Sample? cardFlipSample; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + public RankedPlayCard(RankedPlayCardWithPlaylistItem item) + { + Item = item; + + Size = SIZE; + + playlistItem = item.PlaylistItem.GetBoundCopy(); + + InternalChild = songPreviewContainer = new SongPreviewContainer + { + Enabled = { BindTarget = SongPreviewEnabled }, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + shadow = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = CORNER_RADIUS, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 5, + Colour = Color4.Black.Opacity(0.1f), + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + }, + content = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + cardContent = new Container + { + RelativeSizeAxes = Axes.Both, + Child = new RankedPlayCardBackSide() + }, + selectionOutline = new SelectionOutline + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + } + ] + } + ] + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + cardFlipSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/card-flip-1"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + playlistItem.BindValueChanged(e => onPlaylistItemChanged(e.NewValue)); + if (playlistItem.Value != null) + loadCardContent(playlistItem.Value, false); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + shadow.Scale = content.Scale; + shadow.Size = new Vector2(1 - Elevation * 0.25f); + shadow.Position = new Vector2(-25, 20) * Elevation; + } + + #region beatmap fetching logic & card flip + + private readonly TaskCompletionSource cardRevealed = new TaskCompletionSource(); + + public Task CardRevealed => cardRevealed.Task; + + private void onPlaylistItemChanged(MultiplayerPlaylistItem? playlistItem) + { + if (playlistItem == null) + { + SetContent(new RankedPlayCardBackSide(), true); + return; + } + + loadCardContent(playlistItem, true); + } + + private void loadCardContent(MultiplayerPlaylistItem playlistItem, bool flip) => Task.Run(async () => + { + var beatmap = await beatmapLookupCache.GetBeatmapAsync(playlistItem.BeatmapID).ConfigureAwait(false); + + cardRevealed.TrySetResult(); + + if (beatmap == null) + { + Logger.Log($"Failed to load beatmap {playlistItem.BeatmapID} for playlistItem {playlistItem.ID}.", level: LogLevel.Error); + return; + } + + Schedule(() => + { + SetContent(new RankedPlayCardContent(beatmap), flip); + songPreviewContainer.LoadPreview(beatmap); + }); + }); + + public void SetContent(Drawable newContent, bool flip) + { + if (!flip) + { + cardContent.Child = newContent; + return; + } + + content.ScaleTo(new Vector2(0, 1), 100, Easing.In) + .Then() + .Schedule(() => cardContent.Child = newContent) + .ScaleTo(new Vector2(1), 300, Easing.OutElasticQuarter); + + SamplePlaybackHelper.PlayWithRandomPitch(cardFlipSample); + } + + #endregion + + public void PopOutAndExpire() + { + content.ScaleTo(0, 500, Easing.In); + + this.FadeOut(500) + .Expire(); + } + + private partial class SelectionOutline : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + const float border_width = 4; + + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + // anti-aliasing would create a gap between the border & card here if we used border_width directly + Padding = new MarginPadding(-(border_width - 1)), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = CORNER_RADIUS + border_width, + BorderThickness = border_width, + BorderColour = Color4Extensions.FromHex("72D5FF"), + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 30, + Colour = Color4Extensions.FromHex("72D5FF").Opacity(0.2f), + Hollow = true, + Roundness = 10 + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardBackSide.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardBackSide.cs new file mode 100644 index 000000000000..be8ab9ab526b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardBackSide.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Overlays; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardBackSide : CompositeDrawable + { + public RankedPlayCardBackSide() + { + Size = RankedPlayCard.SIZE; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = RankedPlayCard.CORNER_RADIUS; + + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background1, + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.AttributeListing.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.AttributeListing.cs new file mode 100644 index 000000000000..70f8d312fd8e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.AttributeListing.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent + { + private partial class AttributeListing(APIBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + var rulesetInfo = rulesets.GetRuleset(beatmap.RulesetID); + Debug.Assert(rulesetInfo != null); + var ruleset = rulesetInfo.CreateInstance(); + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(7), + Children = + [ + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = + [ + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Spacing = new Vector2(4), + Children = + [ + new OsuSpriteText + { + Text = "Length", + Font = OsuFont.GetFont(size: 9, weight: FontWeight.Medium), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Text = beatmap.HitLength.ToFormattedDuration(), + Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + ] + }, + + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Spacing = new Vector2(4), + Children = + [ + new OsuSpriteText + { + Text = "BPM", + Font = OsuFont.GetFont(size: 9, weight: FontWeight.Medium), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Text = ((int)beatmap.BPM).ToString(), + Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + ] + }, + ] + }, + ..ruleset.GetBeatmapAttributesForDisplay(beatmap, []) + .Select(attribute => new AttributeRow(attribute)) + ] + }; + } + } + + private partial class AttributeRow(RulesetBeatmapAttribute attribute) : CompositeDrawable + { + private float normalizedValue => float.Clamp(attribute.AdjustedValue / attribute.MaxValue, 0, 1); + + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChildren = + [ + new OsuSpriteText + { + Text = attribute.Label, + Font = OsuFont.GetFont(size: 9, weight: FontWeight.Medium), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + RelativePositionAxes = Axes.X, + Text = attribute.AdjustedValue.ToStandardFormattedString(maxDecimalDigits: 1), + Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + UseFullGlyphHeight = false, + X = 0.65f, + Padding = new MarginPadding { Right = 2 }, + Colour = colours.OnBackground, + }, + new CircularContainer + { + RelativeSizeAxes = Axes.X, + Width = 0.35f, + Height = 2, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Masking = true, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.BackgroundLightest, + }, + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Width = normalizedValue, + Masking = true, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.PrimaryWithContrastToBackground, + }, + ] + } + ] + } + ]; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Colours.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Colours.cs new file mode 100644 index 000000000000..f25e5a00b6ff --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Colours.cs @@ -0,0 +1,77 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent + { + public class CardColours(APIBeatmap beatmap, OsuColour colour) + { + private static readonly Color4 base_background = Color4Extensions.FromHex("#222228"); + + public readonly Color4 Primary = colour.ForStarDifficulty(beatmap.StarRating); + + public Color4 OnPrimary => + beatmap.StarRating >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF + ? colour.Orange1 + : getColour(1f, 0.15f); + + public Colour4 Background => mix(base_background, getColour(0.05f, 0.15f), 0.5f); + + public Colour4 BackgroundLighter => mix(base_background, getColour(0.1f, 0.2f), 0.5f); + + public Colour4 BackgroundLightest => mix(base_background, getColour(0.2f, 0.23f), 0.5f); + + public Color4 OnBackground => getColour(1f, 0.9f, isAccent: true); + + public Color4 Border => beatmap.StarRating > 8.0 ? Color4Extensions.FromHex("34044f") : Primary; + + public Colour4 PrimaryWithContrastToBackground => + beatmap.StarRating >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? OnPrimary : Primary; + + private Color4 getColour(float saturation, float lightness, bool isAccent = false) + { + float hue = Primary.ToHSV().h / 360f; + + // at higher star ratings primary colour can become pure black. in that case we want to just use a very desaturated purple as base + if (beatmap.StarRating >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF) + { + hue = isAccent ? 0.15f : 0.77f; + saturation *= 0.5f; + } + + // colours should generally shift slightly towards blue as they get darker + float shadowHue = 0.66f; + float colourShift = (1 - lightness) * 0.5f; + + // except yellow. yellow just *has* to look bad when you do that with it. it gets to fade to red + if (Math.Abs(hue - 0.16f) < 0.1f) + { + shadowHue = 0; + colourShift = float.Pow(colourShift, 0.25f); + } + + return mix( + Color4.FromHsl(new Vector4(hue, saturation, lightness, 1)), + Color4.FromHsl(new Vector4(shadowHue, saturation, lightness, 1)), + colourShift + ); + } + } + + private static Color4 mix(Color4 lhs, Color4 rhs, float alpha) => new Color4( + r: float.Lerp(lhs.R, rhs.R, alpha), + g: float.Lerp(lhs.G, rhs.G, alpha), + b: float.Lerp(lhs.B, rhs.B, alpha), + a: float.Lerp(lhs.A, rhs.A, alpha) + ); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Cover.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Cover.cs new file mode 100644 index 000000000000..c3e0d364122f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Cover.cs @@ -0,0 +1,55 @@ +// 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.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent + { + private partial class CardCover(APIBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + BufferedContainer coverContainer; + + InternalChildren = + [ + coverContainer = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + GrayscaleStrength = 0.25f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(colours.Background.Opacity(0.2f), colours.Background.Opacity(0.65f)) + } + ]; + + var cover = new OnlineBeatmapSetCover(beatmap.BeatmapSet) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + EdgeSmoothness = new Vector2(2), + }; + + LoadComponentAsync(cover, _ => + { + coverContainer.Add(cover); + cover.FadeInFromZero(200); + }); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Metadata.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Metadata.cs new file mode 100644 index 000000000000..2b935acca6a2 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.Metadata.cs @@ -0,0 +1,203 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent + { + private partial class CardMetadata(APIBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + InternalChildren = + [ + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = + [ + new StarRatingBadge(beatmap) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Top = 4 }, + }, + ] + }, + new LinkFlowContainer(static s => s.ShadowOffset = new Vector2(0, 0.15f)) + { + Name = "Beatmap Metadata", + RelativeSizeAxes = Axes.Both, + TextAnchor = Anchor.BottomLeft, + Padding = new MarginPadding(5) { Bottom = 10 }, + ParagraphSpacing = 0.2f, + }.With(d => + { + d.AddText(new RomanisableString(beatmap.Metadata.TitleUnicode, beatmap.Metadata.Title), static s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)); + + d.NewLine(); + d.AddText(new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist), static s => s.Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold)); + + d.NewParagraph(); + d.AddText("mapped by ", static s => s.Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold)); + d.AddText(beatmap.Metadata.Author.Username, s => + { + s.Font = OsuFont.GetFont(size: 9, weight: FontWeight.SemiBold); + s.Colour = colours.OnBackground; + }); + }), + ]; + } + } + + private partial class StarRatingBadge(APIBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + AutoSizeAxes = Axes.Y; + Width = RankedPlayCard.SIZE.X - 20; + + Masking = true; + CornerRadius = 3; + + InternalChildren = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Primary, + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 3, Vertical = 1 }, + ColumnDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + ], + RowDimensions = [new Dimension(GridSizeMode.AutoSize)], + Content = new Drawable[][] + { + [ + new StarsDisplay(beatmap.StarRating) + { + StarSize = 6, + Colour = colours.OnPrimary, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new TruncatingSpriteText + { + Text = FormattableString.Invariant($"{beatmap.StarRating:F2}"), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Font = OsuFont.GetFont(size: 9, weight: FontWeight.Bold), + Colour = colours.OnPrimary, + }, + ] + } + } + ]; + } + } + + private partial class StarsDisplay(double starRating) : CompositeDrawable + { + public required float StarSize { get; init; } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + FillFlowContainer flow; + + InternalChild = flow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(1), + }; + + int numStars = (int)starRating - 1; + + for (int i = 0; i <= numStars; i++) + { + flow.Add(new SpriteIcon + { + Size = new Vector2(StarSize), + Icon = FontAwesome.Solid.Star, + }); + } + + float lastStarWidth = (int)((starRating % 1) * 4) / 4f; + + if (lastStarWidth > 0) + { + flow.Add(new Container + { + Size = new Vector2(StarSize * lastStarWidth, StarSize), + Masking = true, + Child = new SpriteIcon + { + Icon = FontAwesome.Solid.Star, + Size = new Vector2(StarSize), + } + }); + } + } + } + + private partial class DifficultyNameBadge(APIBeatmap beatmap) : CompositeDrawable + { + public new Axes AutoSizeAxes + { + get => base.AutoSizeAxes; + set => base.AutoSizeAxes = value; + } + + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + Masking = true; + CornerRadius = 3; + InternalChildren = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.BackgroundLighter, + }, + new TruncatingSpriteText + { + MaxWidth = 100f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = beatmap.DifficultyName, + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), + Colour = colours.OnBackground, + Padding = new MarginPadding { Vertical = 1 }, + } + ]; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.cs new file mode 100644 index 000000000000..e75af5029cdd --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardContent.cs @@ -0,0 +1,147 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class RankedPlayCardContent : CompositeDrawable, IHasContextMenu + { + public readonly APIBeatmap Beatmap; + + private CardColours colours = null!; + + [Resolved] + private CardDetailsOverlayContainer? cardDetailsOverlay { get; set; } + + public RankedPlayCardContent(APIBeatmap beatmap) + { + Size = RankedPlayCard.SIZE; + + Beatmap = beatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = RankedPlayCard.CORNER_RADIUS, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Background, + }, + new Container + { + Name = "Top Area", + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Children = + [ + new CardCover(Beatmap) + { + RelativeSizeAxes = Axes.Both, + }, + new CardMetadata(Beatmap) + { + RelativeSizeAxes = Axes.Both, + }, + new DifficultyNameBadge(Beatmap) + { + Width = 100, + AutoSizeAxes = Axes.Y, + + // this container partially overlaps with the bottom area + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + } + ], + }, + new Container + { + Name = "Bottom Area", + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = RankedPlayCard.SIZE.X + 6 }, + Children = + [ + new AttributeListing(Beatmap) + { + RelativeSizeAxes = Axes.Both, + } + ] + }, + ] + }, + new CardBorder() + ]; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.CacheAs(colours = new CardColours(Beatmap, dependencies.Get())); + + return dependencies; + } + + public override bool HandlePositionalInput => true; + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (IsHovered) + cardDetailsOverlay?.ShowCardDetails(this, Beatmap); + } + + private partial class CardBorder : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(CardColours colours) + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = RankedPlayCard.CORNER_RADIUS; + BorderThickness = 1.5f; + BorderColour = ColourInfo.GradientVertical(colours.Border.Opacity(0.5f), colours.Border.Opacity(0)); + + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + EdgeSmoothness = new Vector2(3), + }; + } + } + + [Resolved] + private BeatmapSetOverlay? beatmapSetOverlay { get; set; } + + public MenuItem[] ContextMenuItems => + [ + new OsuMenuItem(ContextMenuStrings.ViewBeatmap, MenuItemType.Highlighted, () => beatmapSetOverlay?.ShowBeatmapSet(Beatmap.BeatmapSet)) + ]; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardExtensions.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardExtensions.cs new file mode 100644 index 000000000000..bb7526f8b8b5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/RankedPlayCardExtensions.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 osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public static class RankedPlayCardExtensions + { + /// + /// Adjusts the transforms of a drawable relative to a parent drawable to match the given drawQuad. + /// + /// the target drawable. + /// screen space drawQuad to fit the drawable to. + /// drawable to calculate the transforms in relation to. + public static T MatchScreenSpaceDrawQuad(this T target, Quad drawQuad, CompositeDrawable parent) where T : Drawable + { + drawQuad = parent.ToLocalSpace(drawQuad); + + var originPosition = target.RelativeOriginPosition; + + // child may not have been made alive yet by the parent so anchor is calculated manually + var anchorPosition = parent.ChildSize * target.RelativeAnchorPosition; + + var positionWithOrigin = Vector2.Lerp( + Vector2.Lerp(drawQuad.TopLeft, drawQuad.TopRight, originPosition.X), + Vector2.Lerp(drawQuad.BottomLeft, drawQuad.BottomRight, originPosition.X), + originPosition.Y + ); + + target.Position = positionWithOrigin - anchorPosition; + + target.Rotation = MathHelper.RadiansToDegrees(new Line(drawQuad.TopLeft, drawQuad.TopRight).Theta); + + target.Scale = new Vector2(Vector2.Distance(drawQuad.TopLeft, drawQuad.TopRight) / target.DrawWidth); + + return target; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/SongPreviewParticleContainer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/SongPreviewParticleContainer.cs new file mode 100644 index 000000000000..89319d9fd008 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Card/SongPreviewParticleContainer.cs @@ -0,0 +1,129 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card +{ + public partial class SongPreviewParticleContainer : CompositeDrawable + { + public SongPreviewParticleContainer() + { + RelativeSizeAxes = Axes.Both; + } + + private Vector2 lastPosition; + + private Texture[] particleTextures = null!; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + const int texture_count = 3; + + particleTextures = new Texture[texture_count]; + + for (int i = 0; i < texture_count; i++) + { + particleTextures[i] = textures.Get($"Online/RankedPlay/note-particle-{i}"); + Debug.Assert(particleTextures[i] != null); + } + } + + public void AddParticles(Drawable source, Color4 seedColour) + { + var drawQuad = ToLocalSpace(source.ScreenSpaceDrawQuad); + + var position = sampleRandomPosition(drawQuad); + + for (int i = 0; i < 10; i++) + { + if (Vector2.Distance(position, lastPosition) > 100) + break; + + position = sampleRandomPosition(drawQuad); + } + + lastPosition = position; + + var texture = particleTextures[RNG.Next(particleTextures.Length)]; + + var particle = new Particle(texture) + { + Position = position, + Rotation = RNG.NextSingle(-3, 3), + Colour = seedColour, + Blending = BlendingParameters.Additive, + }; + + AddInternal(particle); + + particle.ScaleTo(0) + .ScaleTo(RNG.NextSingle(0.75f, 1f), 1000, Easing.OutElasticHalf) + .Then() + .FadeOut(1800, Easing.OutCubic) + .Expire(); + } + + private static Vector2 sampleRandomPosition(Quad quad) + { + static float remap(float value, float fromLower, float fromHigher, float toLower, float toHigher) => + (value - fromLower) / (fromHigher - fromLower) * (toHigher - toLower) + toLower; + + static float randomValue() + { + float x = RNG.NextSingle(); + // using quadratic rational smoothstep to increase the likelihood that particles spawn at the edge of the card + float smoothStep = x * x / (2f * x * x - 2f * x + 1f); + + if (smoothStep < 0.5f) + return remap(smoothStep, 0, 0.5f, -0.05f, 0.15f); + else + return remap(smoothStep, 0.5f, 1f, 0.85f, 1.05f); + } + + var top = Vector2.Lerp(quad.TopLeft, quad.TopRight, randomValue()); + var bottom = Vector2.Lerp(quad.BottomLeft, quad.BottomRight, randomValue()); + + return Vector2.Lerp(top, bottom, randomValue()); + } + + private partial class Particle : Sprite + { + public Particle(Texture texture) + { + Size = new Vector2(40); + Texture = texture; + Origin = Anchor.Centre; + } + + private float initialX; + private readonly float seed = RNG.NextSingle() * MathF.PI * 2; + + protected override void LoadComplete() + { + base.LoadComplete(); + + initialX = X; + } + + protected override void Update() + { + base.Update(); + + X = initialX + (float)Math.Cos(Time.Current * 0.002 + seed) * 5; + Y -= (float)(Time.Elapsed * 0.04f); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/CardFlow.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/CardFlow.cs new file mode 100644 index 000000000000..af9985ac27b6 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/CardFlow.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + /// + /// Container that arranges a collection of s horizontally. + /// Layout is not automatic and has to be triggered by calling + /// + /// + /// Drawables are expected to be added to this container with an Anchor/Origin of . + /// + public partial class CardFlow : Container + { + public float Spacing = 20; + + /// + /// Moves all cards into a horizontal arrangement centered within the container's bounds. + /// + /// delay to be added to the movement of each subsequent card + /// duration of the movement + /// easing of the movement + public void LayoutCards(double stagger = 0, double duration = 400, Easing easing = Easing.OutExpo) + { + // makes sure that all facades had a chance to initialize their transforms based on the provided drawQuad + CheckChildrenLife(); + + float totalWidth = Children.Sum(c => c.LayoutSize.X + Spacing) - Spacing; + + float x = -totalWidth / 2; + + double delay = 0; + + foreach (var card in Children) + { + card.Delay(delay) + .MoveTo(new Vector2(x + card.LayoutSize.X * 0.5f, 0), duration, easing) + .RotateTo(0, duration, easing) + .ScaleTo(1, duration, easing); + + x += card.LayoutSize.X + Spacing; + + delay += stagger; + } + } + + /// + /// + /// + /// + /// + /// + /// + public bool RemoveCard(RankedPlayCardWithPlaylistItem item, [MaybeNullWhen(false)] out RankedPlayCard card, out Quad screenSpaceDrawQuad) + { + card = Children.FirstOrDefault(it => it.Item.Equals(item)); + + if (card == null) + { + screenSpaceDrawQuad = default; + return false; + } + + screenSpaceDrawQuad = card.ScreenSpaceDrawQuad; + + Remove(card, false); + + return true; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayCornerPiece.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayCornerPiece.cs new file mode 100644 index 000000000000..c2a4825de491 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayCornerPiece.cs @@ -0,0 +1,160 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + [Cached] + public partial class RankedPlayCornerPiece : VisibilityContainer + { + private readonly BufferedContainer background; + private readonly Container bottomLayer; + private readonly Container topLayer; + + protected override Container Content { get; } + + public RankedPlayCornerPiece(RankedPlayColourScheme colourScheme, Anchor anchor) + { + Size = new Vector2(345, 100); + + Anchor = Origin = anchor; + + InternalChildren = + [ + background = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2( + (anchor & Anchor.x0) != 0 ? 1 : -1, + (anchor & Anchor.y0) != 0 ? -1 : 1 + ), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Rotation = -2, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Shear = new Vector2(-0.5f, 0), + Padding = new MarginPadding + { + Left = -60, + Bottom = -30, + Top = 20, + Right = 15, + }, + Children = + [ + bottomLayer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 20, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.PrimaryDarkest, + Alpha = 0.2f, + // This is a hack to work around alpha-blending issues when drawing on top of a transparent background without premultiplied alpha + // This method requires that this Drawable is not drawn on top of anything else + Blending = BlendingParameters.Mixture with + { + Destination = BlendingType.Zero, + DestinationAlpha = BlendingType.Zero, + Source = BlendingType.One, + SourceAlpha = BlendingType.One, + } + }, + }, + topLayer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 15, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(colourScheme.Primary, colourScheme.PrimaryDarker.Opacity(0.35f)), + Alpha = 0.75f + }, + }, + } + ] + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = anchor, + Origin = anchor, + Margin = new MarginPadding(18), + Child = Content = new Container + { + Anchor = (anchor & Anchor.x0) != 0 ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = (anchor & Anchor.x0) != 0 ? Anchor.CentreLeft : Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + } + } + ]; + } + + public void OnHealthChanged(int health) + { + background.GrayscaleTo(health <= 0f ? 0.75f : 0, 300); + } + + protected override void Update() + { + base.Update(); + + Width = WidthFor(Parent!.ChildSize.X); + } + + public static float WidthFor(float parentWidth) => float.Clamp(parentWidth * 0.25f, 250, 335); + + protected override void PopIn() + { + this.FadeIn(300); + + Content.Delay(150) + .MoveToX(0, 400, Easing.OutExpo) + .ScaleTo(1f, 400, Easing.OutExpo) + .FadeIn(); + + background.MoveToY(0, 400, Easing.OutExpo); + + bottomLayer.RotateTo(0, 400, Easing.OutQuart); + topLayer.RotateTo(0, 400, Easing.OutQuart); + } + + protected override void PopOut() + { + this.FadeOut(300); + + background.MoveToY((Anchor & Anchor.y0) != 0 ? -60 : 60, 500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.75)); + Content.MoveToX((Anchor & Anchor.x0) != 0 ? -200 : 200, 500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.5)) + .ScaleTo(0.5f, 400, Easing.OutCubic) + .FadeOut(200); + + bottomLayer.RotateTo(-25, 500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.75)); + topLayer.RotateTo(25, 500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.75)); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayScoreCounter.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayScoreCounter.cs new file mode 100644 index 000000000000..d84df287afc6 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayScoreCounter.cs @@ -0,0 +1,234 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Transforms; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + public partial class RankedPlayScoreCounter : CompositeDrawable + { + private readonly FillFlowContainer digitFlow; + private readonly CounterDigit[] digits; + + public required FontUsage Font { get; init; } + + private long value; + + public long Value + { + get => value; + set + { + this.value = value; + if (!IsLoaded) + return; + + updateDigits(); + } + } + + public TransformSequence TransformValueTo(long value, double duration = 0, Easing easing = Easing.None) => this.TransformTo(nameof(Value), value, duration, easing); + + public RankedPlayScoreCounter(int numDigits = 6) + { + digits = new CounterDigit[numDigits]; + + AutoSizeAxes = Axes.Both; + + InternalChildren = + [ + digitFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + } + ]; + } + + public Vector2 Spacing + { + get => digitFlow.Spacing; + set => digitFlow.Spacing = value; + } + + [BackgroundDependencyLoader] + private void load() + { + string templateString = Math.Pow(10, digits.Length - 1).ToString("N0"); + + for (int i = 0, digitIndex = 0; i < templateString.Length; i++) + { + if (char.IsDigit(templateString[i])) + { + digitFlow.Add(digits[digitIndex++] = new CounterDigit + { + Font = Font.With(fixedWidth: true), + }); + } + else + { + digitFlow.Add(new OsuSpriteText + { + Text = templateString[i].ToString(), + Font = Font, + Shadow = false, + }); + } + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateDigits(false); + } + + public void SetValueInstantly(long value) + { + ClearTransforms(true); + this.value = value; + updateDigits(false); + } + + private void updateDigits(bool animated = true) + { + long current = value; + + for (int i = digits.Length - 1; i >= 0; i--) + { + digits[i].Offset = current; + + if (!animated) + digits[i].CompleteAnimations(); + + current /= 10; + } + } + + private partial class CounterDigit : CompositeDrawable + { + private readonly DoubleSpring spring = new DoubleSpring + { + NaturalFrequency = 2.5f, + Damping = 0.8f, + Response = 1f + }; + + public double Offset { get; set; } + + private OsuSpriteText upperDigit = null!; + private OsuSpriteText lowerDigit = null!; + + private BufferedContainer blurContainer = null!; + + public required FontUsage Font { get; init; } + + [BackgroundDependencyLoader] + private void load() + { + Debug.Assert(Font.FixedWidth); + + InternalChild = blurContainer = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + Height = 3f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BackgroundColour = Colour4.White.Opacity(0), + Children = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Height = 1f / 3f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + upperDigit = new OsuSpriteText + { + Text = "9", + Font = Font, + RelativePositionAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shadow = false, + }, + lowerDigit = new OsuSpriteText + { + Text = "0", + Font = Font, + RelativePositionAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shadow = false, + } + ] + } + ] + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Size = lowerDigit.DrawSize; + } + + protected override void Update() + { + base.Update(); + + spring.Damping = spring.Velocity > 30 ? 1f : 0.8f; + + spring.Update(Time.Elapsed, Offset); + + updateState(); + } + + private void updateState() + { + int digit = (int)spring.Current % 10; + if (digit < 0) digit += 10; + + lowerDigit.Text = digit.ToString(); + upperDigit.Text = ((digit + 1) % 10).ToString(); + + float y = (float)(spring.Current % 1); + + if (y < 0) + y = 0; + + upperDigit.Y = (y - 1) * 0.65f; + lowerDigit.Y = y * 0.65f; + + lowerDigit.Alpha = MathF.Pow(1 - y, 2); + upperDigit.Alpha = MathF.Pow(y, 2); + + upperDigit.Scale = new Vector2(float.Lerp(0.5f, 1f, MathF.Sqrt(0.5f + y * 0.5f))); + lowerDigit.Scale = new Vector2(float.Lerp(0.5f, 1f, MathF.Sqrt(1 - y * 0.5f))); + + blurContainer.BlurSigma = new Vector2(0, float.Clamp((float)Math.Abs(spring.Velocity * 0.1f) - 5, 0, 10)); + } + + public void CompleteAnimations() + { + spring.Current = Offset; + spring.PreviousTarget = Offset; + spring.Velocity = 0; + + updateState(); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayStageDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayStageDisplay.cs new file mode 100644 index 000000000000..8bcd66292a80 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayStageDisplay.cs @@ -0,0 +1,293 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.RankedPlay; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + public partial class RankedPlayStageDisplay : VisibilityContainer + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private readonly RankedPlayColourScheme colourScheme; + + private Drawable headingTextBackground = null!; + private Drawable progressBar = null!; + private OsuSpriteText progressText = null!; + + private OsuSpriteText? headingText; + private OsuSpriteText? captionText; + + private DateTimeOffset countdownStartTime; + private DateTimeOffset countdownEndTime; + + public RankedPlayStageDisplay(RankedPlayColourScheme colourScheme) + { + this.colourScheme = colourScheme; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + const float phase_text_background_height = 55; + Vector2 progressBarSize = new Vector2(300, 25); + MarginPadding progressBarMargin = new MarginPadding + { + Left = 40, + Top = phase_text_background_height - progressBarSize.Y / 2 + }; + + InternalChildren = new Drawable[] + { + new BufferedContainer + { + AutoSizeAxes = Axes.Both, + BackgroundColour = colourScheme.Surface.Opacity(0), + Alpha = 0.7f, + Children = new[] + { + headingTextBackground = new Container + { + Height = phase_text_background_height, + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = 3, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Surface.Darken(0.1f), + Alpha = 0.8f + } + }, + new Container + { + Size = progressBarSize, + Margin = progressBarMargin, + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = 3, + BorderThickness = 1f, + BorderColour = ColourInfo.GradientVertical(colourScheme.Surface, colourScheme.SurfaceBorder), + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Surface, + } + }, + } + }, + headingText = new OsuSpriteText + { + Margin = new MarginPadding + { + Top = 5, + Left = 20, + }, + Text = Heading, + Font = OsuFont.TorusAlternate.With(size: 34), + Shadow = false, + }, + new Container + { + Size = progressBarSize, + Shear = OsuGame.SHEAR, + Padding = new MarginPadding { Horizontal = 2.2f, Vertical = 2 }, + Margin = progressBarMargin, + Children = + [ + progressBar = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 2, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.8f, + Colour = ColourInfo.GradientHorizontal(colourScheme.PrimaryDarker, colourScheme.Primary) + }, + new TrianglesV2 + { + Width = progressBarSize.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + SpawnRatio = 0.5f, + ScaleAdjust = 0.75f, + Alpha = 0.1f, + Blending = BlendingParameters.Additive, + Colour = ColourInfo.GradientHorizontal(Color4.Transparent, Color4.White) + }, + ], + }, + progressText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = -OsuGame.SHEAR, + Margin = new MarginPadding + { + Left = 10 + }, + UseFullGlyphHeight = false, + Text = "00:27:123", + Font = OsuFont.TorusAlternate.With(size: 16, fixedWidth: true, weight: FontWeight.SemiBold) + } + ] + }, + captionText = new OsuSpriteText + { + Margin = new MarginPadding + { + Top = 80, + Left = 20 + }, + Colour = CaptionColour ?? colourScheme.Primary, + Text = Caption, + Font = OsuFont.TorusAlternate.With(size: 24, weight: FontWeight.SemiBold) + } + }; + } + + private LocalisableString heading; + + /// + /// Heading text to be displayed indicating the purpose of the current stage. + /// + public LocalisableString Heading + { + get => heading; + set + { + heading = value; + if (headingText != null) + headingText.Text = value; + } + } + + private LocalisableString caption; + + /// + /// Subtitle text to be displayed indicating the action a user should take in the current stage. + /// + public LocalisableString Caption + { + get => caption; + set + { + caption = value; + if (captionText != null) + captionText.Text = value; + } + } + + private Color4? captionColour; + + /// + /// Overrides the default caption colour from the colour scheme with a custom one. + /// + public Color4? CaptionColour + { + get => captionColour; + set + { + captionColour = value; + if (captionText != null) + captionText.Colour = value ?? colourScheme.Primary; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.CountdownStarted += onCountdownStarted; + client.CountdownStopped += onCountdownStopped; + + if (client.Room != null) + { + foreach (var countdown in client.Room.ActiveCountdowns) + onCountdownStarted(countdown); + } + } + + protected override void Update() + { + base.Update(); + + headingTextBackground.Width = headingText!.DrawWidth + 80; + + TimeSpan duration = countdownEndTime - countdownStartTime; + TimeSpan remaining = countdownEndTime - DateTimeOffset.Now; + + if (duration > TimeSpan.Zero) + progressBar.Width = (float)Math.Clamp(remaining / duration, 0, 1); + + int minutes = (int)Math.Max(0, remaining.TotalMinutes); + int seconds = Math.Max(0, remaining.Seconds); + int ms = Math.Max(0, remaining.Milliseconds); + + progressText.Text = $"{minutes:00}:{seconds:00}.{ms:000}"; + } + + private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown) + return; + + countdownStartTime = DateTimeOffset.Now; + countdownEndTime = DateTimeOffset.Now + countdown.TimeRemaining; + }); + + private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown) + return; + + countdownEndTime = DateTimeOffset.Now; + }); + + protected override void PopIn() + { + this.FadeIn(); + } + + protected override void PopOut() + { + this.FadeOut(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.CountdownStarted -= onCountdownStarted; + client.CountdownStopped -= onCountdownStopped; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayUserDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayUserDisplay.cs new file mode 100644 index 000000000000..0195c47945f4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayUserDisplay.cs @@ -0,0 +1,398 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + public partial class RankedPlayUserDisplay : CompositeDrawable + { + public readonly BindableInt Health = new BindableInt + { + MaxValue = 1_000_000, + MinValue = 0, + Value = 1_000_000, + }; + + [Resolved] + private UserLookupCache users { get; set; } = null!; + + private readonly int userId; + private readonly Anchor contentAnchor; + private readonly RankedPlayColourScheme colourScheme; + + private BufferedContainer grayScaleContainer = null!; + + [Resolved] + private RankedPlayCornerPiece? cornerPiece { get; set; } + + public RankedPlayUserDisplay(int userId, Anchor contentAnchor, RankedPlayColourScheme colourScheme) + { + this.userId = userId; + this.contentAnchor = contentAnchor; + this.colourScheme = colourScheme; + } + + [BackgroundDependencyLoader] + private void load() + { + APIUser user = users.GetUserAsync(userId).GetResultSafely()!; + + var shear = contentAnchor == Anchor.TopLeft || contentAnchor == Anchor.BottomRight + ? -OsuGame.SHEAR + : OsuGame.SHEAR; + + InternalChildren = + [ + new CircularContainer + { + Name = "Avatar", + Size = new Vector2(72), + Masking = true, + Anchor = contentAnchor, + Origin = contentAnchor, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Surface, + Alpha = 0.5f, + }, + grayScaleContainer = new BufferedContainer(cachedFrameBuffer: false, pixelSnapping: true) + { + RelativeSizeAxes = Axes.Both, + Child = new UpdateableAvatar(user) + { + RelativeSizeAxes = Axes.Both, + } + } + ] + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = (contentAnchor & Anchor.x0) != 0 ? new MarginPadding { Left = 72 } : new MarginPadding { Right = 72 }, + Direction = FillDirection.Vertical, + Children = + [ + HealthDisplay = new HealthBar(colourScheme, (contentAnchor & Anchor.x0) != 0, shear) + { + Health = { BindTarget = Health }, + RelativeSizeAxes = Axes.X, + Height = 22, + Anchor = contentAnchor, + Origin = contentAnchor, + }, + new OsuSpriteText + { + Name = "Username", + Text = user.Username, + Anchor = contentAnchor, + Origin = contentAnchor, + Padding = new MarginPadding { Horizontal = 4, Vertical = 6 }, + Font = OsuFont.GetFont(size: 24, weight: FontWeight.SemiBold), + UseFullGlyphHeight = false, + }, + ] + } + ]; + } + + public HealthBar HealthDisplay { get; private set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Health.BindValueChanged(e => + { + grayScaleContainer.GrayscaleTo(e.NewValue <= 0 ? 1 : 0, 300); + cornerPiece?.OnHealthChanged(e.NewValue); + }); + } + + public partial class HealthBar : CompositeDrawable + { + private readonly bool leftToRight; + + public readonly BindableInt Health = new BindableInt + { + MaxValue = 1_000_000, + MinValue = 0, + Value = 1_000_000, + }; + + private readonly BindableInt healthTextValue = new BindableInt(); + + /// + /// relative health threshold below which the health bar starts flashing red + /// + public float HealthFlashThreshold { get; set; } = 0.3f; + + private readonly ColourInfo healthBarColour; + + private readonly Container healthBar; + private readonly Box healthBarBackground; + private readonly Container damageIndicator; + private readonly TrianglesV2 triangles; + private readonly SpriteIcon heartIcon; + private readonly OsuSpriteText healthText; + + /// + /// Impact position for damage animation + /// + public Vector2 ScreenSpaceImpactPosition + { + get + { + var rect = healthBar.ScreenSpaceDrawQuad.AABBFloat; + + return leftToRight ? new Vector2(rect.Right, rect.Centre.Y) : new Vector2(rect.Left, rect.Centre.Y); + } + } + + public HealthBar(RankedPlayColourScheme colourScheme, bool leftToRight, Vector2 shear) + { + this.leftToRight = leftToRight; + + Shear = shear; + + Anchor contentAnchor = leftToRight ? Anchor.CentreLeft : Anchor.CentreRight; + + BufferedContainer content; + + InternalChildren = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 3, + BorderThickness = 1f, + BorderColour = ColourInfo.GradientVertical(colourScheme.Surface, colourScheme.SurfaceBorder), + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Surface, + Alpha = 0.8f, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 2.2f, Vertical = 2 }, // slightly different ratio to account for shear + Children = + [ + healthBar = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 2, + Anchor = contentAnchor, + Origin = contentAnchor, + Children = + [ + healthBarBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.8f, + Colour = healthBarColour = leftToRight + ? ColourInfo.GradientHorizontal(colourScheme.PrimaryDarker, colourScheme.Primary) + : ColourInfo.GradientHorizontal(colourScheme.Primary, colourScheme.PrimaryDarker), + }, + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Y, + Anchor = contentAnchor, + Origin = contentAnchor, + SpawnRatio = 0.5f, + ScaleAdjust = 0.75f, + Alpha = 0.1f, + Blending = BlendingParameters.Additive, + Colour = leftToRight + ? ColourInfo.GradientHorizontal(Color4.Transparent, Color4.White) + : ColourInfo.GradientHorizontal(Color4.White, Color4.Transparent), + }, + ], + }, + ] + }, + content = new BufferedContainer(pixelSnapping: true) + { + RelativeSizeAxes = Axes.Both, + Shear = -shear, + BackgroundColour = Color4.White.Opacity(0), // workaround for non-premultiplied alpha blending of white content on transparent background + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(3), + Padding = new MarginPadding { Horizontal = 10 }, + Children = + [ + new Container + { + Size = new Vector2(10), + Anchor = contentAnchor, + Origin = contentAnchor, + Child = heartIcon = new SpriteIcon + { + Icon = FontAwesome.Solid.Heart, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }, + healthText = new OsuSpriteText + { + Text = "1,000,000", + Anchor = contentAnchor, + Origin = contentAnchor, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Medium, fixedWidth: true), + Spacing = new Vector2(-1, 0), + UseFullGlyphHeight = false, + Padding = new MarginPadding { Top = 1 }, + Shadow = false, + } + ] + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 2.2f, Vertical = 2 }, // slightly different ratio to account for shear + Children = + [ + damageIndicator = new Container + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + Anchor = contentAnchor, + Origin = contentAnchor, + Masking = true, + CornerRadius = 2, + Alpha = 0, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 25, + Colour = Color4Extensions.FromHex("FF171B").Opacity(0.5f), + Roundness = 10, + Hollow = true, + }, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + }, + content.CreateView().With(d => + { + d.SynchronisedDrawQuad = true; + d.Colour = Color4.Red; + }) + ], + }, + ] + }, + ]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Health.BindValueChanged(onHealthChanged, true); + + healthTextValue.BindValueChanged(e => healthText.Text = FormattableString.Invariant($"{e.NewValue:N0}"), true); + + FinishTransforms(true); + + Scheduler.AddDelayed(flashHealth, 1000, true); + } + +#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value + private float normalizedHealth; + private float normalizedHealthWithDamage; +#pragma warning restore CS0649 // Field is never assigned to, and will always have its default value + + private void onHealthChanged(ValueChangedEvent e) + { + this.TransformBindableTo(healthTextValue, e.NewValue, 500, Easing.OutExpo); + + bool isHealthDecrease = e.NewValue < e.OldValue; + + if (isHealthDecrease) + { + damageIndicator.FadeIn(50) + .Then(delay: 1100) + .FadeOut(200); + + healthBarBackground.FadeColour(Color4.Red, 100) + .Then() + .FadeColour(healthBarColour, 1000); + + this.TransformTo(nameof(normalizedHealthWithDamage), Health.NormalizedValue, 400, Easing.OutExpo) + .Then(500) + .TransformTo(nameof(normalizedHealth), Health.NormalizedValue, 800, Easing.OutExpo); + } + + else + { + this.TransformTo(nameof(normalizedHealthWithDamage), Health.NormalizedValue, 800, Easing.OutExpo) + .TransformTo(nameof(normalizedHealth), Health.NormalizedValue, 800, Easing.OutExpo); + } + } + + protected override void Update() + { + base.Update(); + + triangles.Width = DrawWidth; + healthBar.Width = normalizedHealth; + + damageIndicator.X = leftToRight ? normalizedHealthWithDamage : -normalizedHealthWithDamage; + damageIndicator.Width = float.Clamp(normalizedHealth - normalizedHealthWithDamage, 0, 1); + } + + private void flashHealth() + { + if (Health.NormalizedValue > HealthFlashThreshold) + return; + + var almostRed = Interpolation.ValueAt(0.75, healthBarColour, ColourInfo.SingleColour(Color4.Red), 0.0, 1.0); + + healthBarBackground.FadeColour(almostRed, 150) + .Then() + .FadeColour(healthBarColour, 800); + + heartIcon + .ScaleTo(0.8f, 150, Easing.Out) + .Then() + .ScaleTo(1f, 400, Easing.OutElasticHalf); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs new file mode 100644 index 000000000000..69da87aaa08e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/DiscardScreen.cs @@ -0,0 +1,365 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Localisation; +using osu.Game.Audio; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class DiscardScreen : RankedPlaySubScreen + { + // When the 'time running out' warning sample starts to play (in remaining seconds) + private const int warning_time_threshold = 10; + + public CardFlow CenterRow { get; private set; } = null!; + + protected override LocalisableString StageHeading => "Discard Phase"; + protected override LocalisableString StageCaption => "Replace cards from your hand"; + + private PlayerHandOfCards playerHand = null!; + private ShearedButton discardButton = null!; + private OsuTextFlowContainer explainer = null!; + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private Sample? cardAddSample; + private Sample? cardDiscardSample; + + private const int card_play_samples = 2; + private Sample?[]? cardPlaySamples; + + /// + /// Whether the local user has discarded cards. + /// + private bool hasDiscardedCards; + + private Sample? timeRunningOutSample; + private SampleChannel? timeRunningOutSampleChannel; + + private DateTimeOffset stageEndTime; + private TimeSpan stageDuration; + + public DiscardScreen() + { + StageDisplay.CaptionColour = Color4.White; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + var matchState = Client.Room?.MatchState as RankedPlayRoomState; + + Debug.Assert(matchState != null); + + Children = + [ + CenterRow = new CardFlow + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + discardButton = new ShearedButton + { + Name = "Discard Button", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 150, + Action = onDiscardButtonClicked, + Enabled = { Value = true }, + } + ]; + + CenterColumn.Children = + [ + playerHand = new PlayerHandOfCards + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + SelectionMode = HandSelectionMode.Multiple, + }, + explainer = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 24)) + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.BottomCentre, + TextAnchor = Anchor.TopCentre, + Y = 250, + ParagraphSpacing = 1, + Alpha = 0, + }.With(d => + { + d.AddParagraph("These are your cards for this match!"); + d.AddParagraph("When it’s your turn, you can play a card to go head-to-head against your opponent!"); + }) + ]; + + cardAddSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/card-add-1"); + cardDiscardSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/card-discard-1"); + + cardPlaySamples = new Sample?[card_play_samples]; + for (int i = 0; i < card_play_samples; i++) + cardPlaySamples[i] = audio.Samples.Get($@"Multiplayer/Matchmaking/Ranked/card-play-{1 + i}"); + + timeRunningOutSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/time-running-out"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + matchInfo.PlayerCardAdded += cardAdded; + matchInfo.PlayerCardRemoved += cardRemoved; + + playerHand.SelectionChanged += onSelectionChanged; + + Client.CountdownStarted += onCountdownStarted; + Client.CountdownStopped += onCountdownStopped; + + if (Client.Room != null) + { + foreach (var countdown in Client.Room.ActiveCountdowns) + onCountdownStarted(countdown); + } + + onSelectionChanged(); + } + + private bool shouldPlayWarningSample + => matchInfo.Stage.Value == RankedPlayStage.CardDiscard + && stageDuration > TimeSpan.FromSeconds(warning_time_threshold) + && stageEndTime - DateTimeOffset.Now < TimeSpan.FromSeconds(warning_time_threshold) + && !hasDiscardedCards; + + protected override void Update() + { + base.Update(); + + if (shouldPlayWarningSample) + { + timeRunningOutSampleChannel ??= timeRunningOutSample?.GetChannel(); + + if (timeRunningOutSampleChannel == null || timeRunningOutSampleChannel.Playing) + return; + + timeRunningOutSampleChannel.ManualFree = true; + timeRunningOutSampleChannel.Looping = true; + timeRunningOutSampleChannel.Play(); + } + else + timeRunningOutSampleChannel?.Stop(); + } + + public override void OnEntering(RankedPlaySubScreen? previous) + { + base.OnEntering(previous); + + var screenBottomCenter = new Vector2(DrawWidth / 2, DrawHeight); + int cardCount = 0; + + foreach (var card in matchInfo.PlayerCards) + { + playerHand.AddCard(card, c => + { + c.Position = ToSpaceOfOtherDrawable(screenBottomCenter, playerHand); + }); + Scheduler.AddDelayed(() => + { + SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); + }, 50 * cardCount); + cardCount++; + } + + playerHand.UpdateLayout(stagger: 50); + } + + private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown) + return; + + stageEndTime = DateTimeOffset.Now + countdown.TimeRemaining; + stageDuration = countdown.TimeRemaining; + }); + + private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown) + return; + + stageEndTime = DateTimeOffset.Now; + stageDuration = TimeSpan.Zero; + }); + + private void onSelectionChanged() + { + if (playerHand.Selection.Any()) + discardButton.Text = $"Replace {"card".ToQuantity(playerHand.Selection.Count())}"; + else + discardButton.Text = "Keep cards"; + } + + private void onDiscardButtonClicked() + { + discardButton.Hide(); + + Client.DiscardCards(playerHand.Selection.Select(it => it.Card).ToArray()).FireAndForget(); + playerHand.SelectionMode = HandSelectionMode.Disabled; + + hasDiscardedCards = true; + } + + private readonly List discardedCards = new List(); + + private void cardRemoved(RankedPlayCardWithPlaylistItem item) => discardedCards.Add(item); + + private void playDiscardAnimation() + { + const double stagger = 100; + double delay = 0; + + foreach (var item in discardedCards) + { + if (!playerHand.RemoveCard(item, out var card, out Quad drawQuad)) + return; + + card.Anchor = Anchor.Centre; + card.Origin = Anchor.Centre; + + card.MatchScreenSpaceDrawQuad(drawQuad, CenterRow); + + CenterRow.Add(card); + + using (BeginDelayedSequence(1000 + delay)) + { + card.PopOutAndExpire(); + } + + Scheduler.AddDelayed(() => + { + SamplePlaybackHelper.PlayWithRandomPitch(cardPlaySamples); + }, delay); + + delay += stagger; + } + + Scheduler.AddDelayed(() => + { + cardDiscardSample?.Play(); + }, 1000); + + discardedCards.Clear(); + CenterRow.LayoutCards(stagger: stagger); + } + + private double nextCardDrawTime; + private double earliestPresentationTime; + + private void cardAdded(RankedPlayCardWithPlaylistItem card) + { + if (discardedCards.Count > 0) + { + playDiscardAnimation(); + nextCardDrawTime = Math.Max(nextCardDrawTime, Time.Current + 2000); + } + + double delay = Math.Max(0, nextCardDrawTime - Time.Current); + nextCardDrawTime = Time.Current + delay + 100; + + earliestPresentationTime = Time.Current + 3500; + + Scheduler.AddDelayed(() => + { + playerHand.AddCard(card, d => + { + d.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth, DrawHeight * 0.5f), playerHand); + d.Rotation = -30; + }); + + SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); + }, delay); + } + + public void PresentRemainingCards() + { + discardButton.Hide(); + + double presentationTime = Math.Max(earliestPresentationTime, Time.Current); + Scheduler.AddDelayed(presentRemainingCards, presentationTime - Time.Current); + } + + private void presentRemainingCards() + { + int delay = 0; + + foreach (var item in matchInfo.PlayerCards) + { + if (playerHand.RemoveCard(item, out var card, out Quad drawQuad)) + { + card.MatchScreenSpaceDrawQuad(drawQuad, CenterRow); + + CenterRow.Add(card); + + Scheduler.AddDelayed(() => + { + SamplePlaybackHelper.PlayWithRandomPitch(cardPlaySamples); + }, delay); + + delay += 50; + } + else + { + CenterRow.Add(new RankedPlayCard(item) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + } + + CenterRow.LayoutCards(stagger: 50, duration: 600); + + explainer + .Delay(100) + .MoveToOffset(new Vector2(0, 50)) + .MoveToOffset(new Vector2(0, -50), 600, Easing.OutExpo) + .FadeIn(250); + } + + protected override void Dispose(bool isDisposing) + { + timeRunningOutSampleChannel?.Stop(); + timeRunningOutSampleChannel?.Dispose(); + + matchInfo.PlayerCardAdded -= cardAdded; + matchInfo.PlayerCardRemoved -= cardRemoved; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/EndedScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/EndedScreen.cs new file mode 100644 index 000000000000..b76f8a7944fa --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/EndedScreen.cs @@ -0,0 +1,211 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class EndedScreen : RankedPlaySubScreen + { + /// + /// Invoked when the user requests to exit this screen. + /// + public Action? ExitRequested { get; init; } + + protected override LocalisableString StageHeading => "Results"; + protected override LocalisableString StageCaption => string.Empty; + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private OsuSpriteText titleText = null!; + private Drawable titleSeparator = null!; + private OsuTextFlowContainer localRatingText = null!; + private OsuTextFlowContainer opponentRatingText = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + CenterColumn.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new[] + { + titleText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "VICTORY", + Font = OsuFont.Torus.With(size: 100, weight: FontWeight.SemiBold), + UseFullGlyphHeight = false, + Colour = colours.Green1, + }, + titleSeparator = new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = 2, + Colour = colours.Green1 + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = 8, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.5f + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Shear = -OsuGame.SHEAR, + Children = new Drawable[] + { + localRatingText = new OsuTextFlowContainer(s => s.Font = OsuFont.Style.Heading1) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + } + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = 8, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.5f + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Shear = -OsuGame.SHEAR, + Children = new Drawable[] + { + opponentRatingText = new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + } + } + } + } + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ShearedButton + { + Width = 100, + Text = "Quit", + Action = () => ExitRequested?.Invoke(false), + DarkerColour = colours.Red3, + LighterColour = colours.Red4, + }, + new ShearedButton + { + Width = 200, + Text = "Play Again", + Action = () => ExitRequested?.Invoke(true), + DarkerColour = colours.Green3, + LighterColour = colours.Green4, + }, + } + } + } + }; + + RankedPlayUserInfo localUser = matchInfo.RoomState.Users[Client.LocalUser!.UserID]; + RankedPlayUserInfo otherUser = matchInfo.RoomState.Users.Values.Single(u => u != localUser); + + if (matchInfo.RoomState.WinningUserId == null) + { + titleText.Text = "DRAW"; + titleText.Colour = titleSeparator.Colour = colours.Orange1; + } + else if (matchInfo.RoomState.WinningUserId == Client.LocalUser!.UserID) + { + titleText.Text = "VICTORY"; + titleText.Colour = titleSeparator.Colour = colours.Green1; + } + else + { + titleText.Text = "DEFEAT"; + titleText.Colour = titleSeparator.Colour = colours.Red1; + } + + localRatingText.AddText("Your Rating: ", s => s.Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular)); + localRatingText.AddText(localUser.RatingAfter.ToString("N0"), s => s.Font = OsuFont.Style.Heading1); + localRatingText.AddText($" ({localUser.RatingAfter - localUser.Rating:+0;-0;+0})", s => + { + s.Font = OsuFont.Style.Caption1; + s.Colour = localUser.RatingAfter >= localUser.Rating ? colours.GreenDark : colours.RedDark; + }); + + opponentRatingText.AddText("Opponent Rating: ", s => s.Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular)); + opponentRatingText.AddText(otherUser.RatingAfter.ToString("N0"), s => s.Font = OsuFont.Style.Heading1); + opponentRatingText.AddText($" ({otherUser.RatingAfter - otherUser.Rating:+0;-0;+0})", s => + { + s.Font = OsuFont.Style.Caption1; + s.Colour = otherUser.RatingAfter >= otherUser.Rating ? colours.GreenDark : colours.RedDark; + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayScreen.cs new file mode 100644 index 000000000000..251c30499546 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayScreen.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayScreen : RankedPlaySubScreen + { + protected override LocalisableString StageHeading => "Gameplay"; + protected override LocalisableString StageCaption => string.Empty; + + [BackgroundDependencyLoader] + private void load() + { + CenterColumn.Children = + [ + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(20), + Children = + [ + new OsuSpriteText + { + Text = "Gameplay is in progress...", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.GetFont(typeface: Typeface.TorusAlternate, size: 42, weight: FontWeight.Regular), + }, + ] + }, + ]; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.DifficultyDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.DifficultyDisplay.cs new file mode 100644 index 000000000000..65af9c57a3b3 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.DifficultyDisplay.cs @@ -0,0 +1,234 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Screens.Select; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayWarmupScreen + { + private partial class DifficultyDisplay : CompositeDrawable + { + private const float border_weight = 2; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private readonly APIBeatmap beatmap; + + private StarRatingDisplay starRatingDisplay = null!; + private FillFlowContainer nameLine = null!; + private OsuSpriteText difficultyText = null!; + private OsuSpriteText mappedByText = null!; + private OsuSpriteText mapperText = null!; + + private BeatmapTitleWedge.DifficultyStatisticsDisplay countStatisticsDisplay = null!; + private BeatmapTitleWedge.DifficultyStatisticsDisplay difficultyStatisticsDisplay = null!; + + public DifficultyDisplay(APIBeatmap beatmap) + { + this.beatmap = beatmap; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 10; + Shear = OsuGame.SHEAR; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new ShearAligningWrapper(new GridContainer + { + Shear = -OsuGame.SHEAR, + AlwaysPresent = true, + RelativeSizeAxes = Axes.X, + Height = 20, + Margin = new MarginPadding { Vertical = 5f }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 6), + new Dimension(), + }, + Content = new[] + { + new[] + { + starRatingDisplay = new StarRatingDisplay(default, animated: true) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + Empty(), + nameLine = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Bottom = 2f }, + Children = new Drawable[] + { + difficultyText = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + mappedByText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = " mapped by ", + Font = OsuFont.Style.Body, + }, + mapperText = new TruncatingSpriteText + { + Shadow = true, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + }, + }, + } + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = 53, + Padding = new MarginPadding { Bottom = border_weight, Right = border_weight }, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 10 - border_weight, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5.Opacity(0.8f), + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 20f, Vertical = 7.5f }, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 30), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + countStatisticsDisplay = new BeatmapTitleWedge.DifficultyStatisticsDisplay + { + RelativeSizeAxes = Axes.X, + }, + Empty(), + difficultyStatisticsDisplay = new BeatmapTitleWedge.DifficultyStatisticsDisplay(autoSize: true), + } + }, + } + }, + } + }), + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + MultiplayerPlaylistItem item = client.Room!.CurrentPlaylistItem; + + RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!; + Ruleset rulesetInstance = ruleset.CreateInstance(); + BeatmapInfo? localBeatmap = + beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID); + WorkingBeatmap workingBeatmap = beatmapManager.GetWorkingBeatmap(localBeatmap); + IBeatmap playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset); + + difficultyText.Text = beatmap.DifficultyName; + mapperText.Text = beatmap.Metadata.Author.Username; + starRatingDisplay.Current.Value = new StarDifficulty(beatmap.StarRating, beatmap.MaxCombo ?? 0); + + countStatisticsDisplay.Statistics = playableBeatmap.GetStatistics() + .Select(s => new BeatmapTitleWedge.StatisticDifficulty.Data(s.Name, s.BarDisplayLength ?? 0, s.BarDisplayLength ?? 0, 1, s.Content)) + .ToList(); + + difficultyStatisticsDisplay.Statistics = rulesetInstance.GetBeatmapAttributesForDisplay(beatmap, []) + .Select(a => new BeatmapTitleWedge.StatisticDifficulty.Data(a)) + .ToList(); + } + + protected override void Update() + { + base.Update(); + + difficultyText.MaxWidth = Math.Max(nameLine.DrawWidth - mappedByText.DrawWidth - mapperText.DrawWidth - 20, 0); + + // Use difficulty colour until it gets too dark to be visible against dark backgrounds. + Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : starRatingDisplay.DisplayedDifficultyColour; + + difficultyText.Colour = col; + mappedByText.Colour = col; + countStatisticsDisplay.AccentColour = col; + difficultyStatisticsDisplay.AccentColour = col; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.MetadataWedge.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.MetadataWedge.cs new file mode 100644 index 000000000000..7ffb1fba6a96 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.MetadataWedge.cs @@ -0,0 +1,260 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Localisation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayWarmupScreen + { + private partial class MetadataWedge : CompositeDrawable + { + private readonly APIBeatmap beatmap; + + private BeatmapMetadataWedge.MetadataDisplay creator = null!; + private BeatmapMetadataWedge.MetadataDisplay source = null!; + private BeatmapMetadataWedge.MetadataDisplay genre = null!; + private BeatmapMetadataWedge.MetadataDisplay language = null!; + private BeatmapMetadataWedge.MetadataDisplay userTags = null!; + private BeatmapMetadataWedge.MetadataDisplay mapperTags = null!; + private BeatmapMetadataWedge.MetadataDisplay submitted = null!; + private BeatmapMetadataWedge.MetadataDisplay ranked = null!; + + private BeatmapMetadataWedge.SuccessRateDisplay successRateDisplay = null!; + private BeatmapMetadataWedge.UserRatingDisplay userRatingDisplay = null!; + private BeatmapMetadataWedge.RatingSpreadDisplay ratingSpreadDisplay = null!; + private BeatmapMetadataWedge.FailRetryDisplay failRetryDisplay = null!; + + public MetadataWedge(APIBeatmap beatmap) + { + this.beatmap = beatmap; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Width = 0.9f; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Shear = OsuGame.SHEAR, + Children = new[] + { + new ShearAligningWrapper(new Container + { + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 35, Vertical = 16 }, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(), + new Dimension(), + }, + Content = new[] + { + new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + creator = new BeatmapMetadataWedge.MetadataDisplay(EditorSetupStrings.Creator), + genre = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoGenre), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + source = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoSource), + language = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoLanguage), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + submitted = new BeatmapMetadataWedge.MetadataDisplay(SongSelectStrings.Submitted), + ranked = new BeatmapMetadataWedge.MetadataDisplay(SongSelectStrings.Ranked), + }, + }, + }, + }, + }, + userTags = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoUserTags) + { + Alpha = 0, + }, + mapperTags = new BeatmapMetadataWedge.MetadataDisplay(BeatmapsetsStrings.ShowInfoMapperTags), + }, + }, + }, + }, + }, + }), + new ShearAligningWrapper(new Container + { + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Content = new[] + { + new[] + { + successRateDisplay = new BeatmapMetadataWedge.SuccessRateDisplay(), + Empty(), + userRatingDisplay = new BeatmapMetadataWedge.UserRatingDisplay(), + Empty(), + ratingSpreadDisplay = new BeatmapMetadataWedge.RatingSpreadDisplay(), + }, + }, + }, + } + }), + new ShearAligningWrapper(new Container + { + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Child = failRetryDisplay = new BeatmapMetadataWedge.FailRetryDisplay(), + }, + }, + }), + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var metadata = beatmap.Metadata; + var beatmapSet = beatmap.BeatmapSet!; + + creator.Data = (metadata.Author.Username, null); + + if (!string.IsNullOrEmpty(metadata.Source)) + source.Data = (metadata.Source, null); + else + source.Data = ("-", null); + + if (!string.IsNullOrEmpty(metadata.Tags)) + mapperTags.Tags = (metadata.Tags.Split(' '), _ => { }); + else + mapperTags.Tags = (Array.Empty(), _ => { }); + + submitted.Date = beatmapSet.Submitted; + ranked.Date = beatmapSet.Ranked; + + genre.Data = (beatmapSet.Genre.Name, null); + language.Data = (beatmapSet.Language.Name, null); + + userRatingDisplay.Data = beatmapSet.Ratings; + ratingSpreadDisplay.Data = beatmapSet.Ratings; + successRateDisplay.Data = (beatmap.PassCount, beatmap.PlayCount); + failRetryDisplay.Data = beatmap.FailTimes ?? new APIFailTimes(); + + var tagsById = beatmapSet.RelatedTags?.ToDictionary(t => t.Id) ?? new Dictionary(); + string[] topUserTags = beatmap.TopTags? + .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) + .Where(t => t.relatedTag != null) + // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria + .OrderByDescending(t => t.topTag.VoteCount) + .ThenBy(t => t.relatedTag!.Name) + .Select(t => t.relatedTag!.Name) + .ToArray() ?? []; + + userTags.Tags = (topUserTags, _ => { }); + + if (topUserTags.Length > 0) + userTags.Show(); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.TitleWedge.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.TitleWedge.cs new file mode 100644 index 000000000000..381b12bbf8f0 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.TitleWedge.cs @@ -0,0 +1,167 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Select; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayWarmupScreen + { + private partial class TitleWedge : CompositeDrawable + { + private const float corner_radius = 10; + + private readonly APIBeatmap beatmap; + + private BeatmapSetOnlineStatusPill statusPill = null!; + private MarqueeContainer titleLabel = null!; + private MarqueeContainer artistLabel = null!; + + private BeatmapTitleWedge.StatisticPlayCount playCount = null!; + private BeatmapTitleWedge.FavouriteButton favouriteButton = null!; + private BeatmapTitleWedge.Statistic lengthStatistic = null!; + private BeatmapTitleWedge.Statistic bpmStatistic = null!; + + public TitleWedge(APIBeatmap beatmap) + { + this.beatmap = beatmap; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + Shear = OsuGame.SHEAR; + CornerRadius = corner_radius; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding + { + Top = SongSelect.WEDGE_CONTENT_MARGIN, + Left = SongSelect.WEDGE_CONTENT_MARGIN + }, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + new ShearAligningWrapper(statusPill = new BeatmapSetOnlineStatusPill + { + Shear = -OsuGame.SHEAR, + ShowUnknownStatus = true, + TextSize = OsuFont.Style.Caption1.Size, + TextPadding = new MarginPadding { Horizontal = 6, Vertical = 1 }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Title.Size, + Margin = new MarginPadding { Bottom = -4f }, + Child = titleLabel = new MarqueeContainer + { + OverflowSpacing = 50, + } + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Heading2.Size, + Margin = new MarginPadding { Left = 1f }, + Child = artistLabel = new MarqueeContainer + { + OverflowSpacing = 50, + } + }), + new ShearAligningWrapper(new FillFlowContainer + { + Shear = -OsuGame.SHEAR, + AutoSizeAxes = Axes.X, + Height = 30, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2f, 0f), + Children = new Drawable[] + { + playCount = new BeatmapTitleWedge.StatisticPlayCount(background: true, leftPadding: SongSelect.WEDGE_CONTENT_MARGIN, minSize: 50f) + { + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + }, + favouriteButton = new BeatmapTitleWedge.FavouriteButton(), + lengthStatistic = new BeatmapTitleWedge.Statistic(OsuIcon.Clock), + bpmStatistic = new BeatmapTitleWedge.Statistic(OsuIcon.Metronome) + { + TooltipText = BeatmapsetsStrings.ShowStatsBpm, + Margin = new MarginPadding { Left = 5f }, + }, + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + Padding = new MarginPadding { Right = -SongSelect.WEDGE_CONTENT_MARGIN }, + Child = new DifficultyDisplay(beatmap), + }), + }, + } + }; + + statusPill.Status = beatmap.Status; + + var titleText = new RomanisableString(beatmap.BeatmapSet!.TitleUnicode, beatmap.BeatmapSet.Title); + titleLabel.CreateContent = () => new OsuSpriteText + { + Text = titleText, + Shadow = true, + Font = OsuFont.Style.Title, + }; + + var artistText = new RomanisableString(beatmap.BeatmapSet.ArtistUnicode, beatmap.BeatmapSet.Artist); + artistLabel.CreateContent = () => new OsuSpriteText + { + Text = artistText, + Shadow = true, + Font = OsuFont.Style.Heading2, + }; + + double rate = ModUtils.CalculateRateWithMods([]); // Todo: mods + double drainLength = Math.Round(beatmap.Length / rate); + double hitLength = Math.Round(beatmap.HitLength / rate); + + lengthStatistic.Text = hitLength.ToFormattedDuration(); + lengthStatistic.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()); + bpmStatistic.Text = beatmap.BPM.ToLocalisableString(); + + playCount.Value = new BeatmapTitleWedge.StatisticPlayCount.Data(beatmap.PlayCount, beatmap.UserPlayCount); + favouriteButton.SetBeatmapSet(beatmap.BeatmapSet); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs new file mode 100644 index 000000000000..c01a732c7411 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/GameplayWarmupScreen.cs @@ -0,0 +1,186 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class GameplayWarmupScreen : RankedPlaySubScreen + { + public override bool ShowBeatmapBackground => true; + + protected override LocalisableString StageHeading => "Gameplay"; + protected override LocalisableString StageCaption => string.Empty; + + [Cached(typeof(IBindable))] + private readonly Bindable lastLookupResult = new Bindable(); + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + [Resolved] + private OverlayColourProvider overlayColours { get; set; } = null!; + + private Container cardColumn = null!; + private Drawable separator = null!; + private Drawable detailsColumn = null!; + private Drawable wedgesContainer = null!; + + [BackgroundDependencyLoader] + private void load() + { + APIBeatmap beatmap = beatmapLookupCache.GetBeatmapAsync(Client.Room!.CurrentPlaylistItem.BeatmapID).GetResultSafely()!; + lastLookupResult.Value = SongSelect.BeatmapSetLookupResult.Completed(beatmap.BeatmapSet); + + var matchState = Client.Room?.MatchState as RankedPlayRoomState; + Debug.Assert(matchState != null); + + Children = + [ + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Spacing = new Vector2(20), + LayoutDuration = 500, + LayoutEasing = Easing.OutPow10, + Children = new[] + { + cardColumn = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + }, + separator = new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(2, 50), + Scale = new Vector2(1, 0), + Alpha = 0, + Colour = overlayColours.Colour0 + }, + detailsColumn = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + Scale = new Vector2(0.8f), + Alpha = 0, + Child = wedgesContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = OsuGame.SHEAR, + X = -20, + Padding = new MarginPadding + { + Left = -SongSelect.CORNER_RADIUS_HIDE_OFFSET, + }, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 4f), + Direction = FillDirection.Vertical, + Children = + [ + new ShearAligningWrapper(new TitleWedge(beatmap)) + { + Shear = -OsuGame.SHEAR, + }, + new ShearAligningWrapper(new MetadataWedge(beatmap)) + { + Shear = -OsuGame.SHEAR, + }, + ] + } + } + } + } + } + ]; + } + + public override void OnEntering(RankedPlaySubScreen? previous) + { + base.OnEntering(previous); + + if (matchInfo.LastPlayedCard == null) + return; + + RankedPlayCard? card = null; + + switch (previous) + { + case PickScreen pick: + { + if (pick.CenterRow.RemoveCard(matchInfo.LastPlayedCard, out card, out var screenSpaceDrawQuad)) + card.MatchScreenSpaceDrawQuad(screenSpaceDrawQuad, cardColumn); + break; + } + + case OpponentPickScreen opponentPick: + { + if (opponentPick.CenterRow.RemoveCard(matchInfo.LastPlayedCard, out card, out var screenSpaceDrawQuad)) + card.MatchScreenSpaceDrawQuad(screenSpaceDrawQuad, cardColumn); + break; + } + } + + if (card == null) + { + Logger.Log($"Played card {matchInfo.LastPlayedCard.Card.ID} was not on the screen.", level: LogLevel.Error); + + card = new RankedPlayCard(matchInfo.LastPlayedCard) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + cardColumn.Add(card); + + separator.AlwaysPresent = true; + detailsColumn.AlwaysPresent = true; + + using (BeginDelayedSequence(500)) + { + separator.FadeIn(); + separator.ScaleTo(Vector2.One, 1000, Easing.OutPow10); + + using (BeginDelayedSequence(200)) + { + detailsColumn.FadeIn(800, Easing.OutPow10); + wedgesContainer.MoveToX(0, 1000, Easing.OutPow10); + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/HamburgerMenu.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/HamburgerMenu.cs new file mode 100644 index 000000000000..2d422b525212 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/HamburgerMenu.cs @@ -0,0 +1,116 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Screens; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class HamburgerMenu : IconButton, IHasPopover + { + public HamburgerMenu() + { + Icon = FontAwesome.Solid.Bars; + Action = this.ShowPopover; + } + + public Framework.Graphics.UserInterface.Popover GetPopover() => new Popover(); + + private partial class Popover : OsuPopover + { + [Resolved] + private RankedPlayScreen? rankedPlayScreen { get; set; } + + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + private FillFlowContainer buttonFlow = null!; + + [BackgroundDependencyLoader] + private void load() + { + Content.Padding = new MarginPadding(5); + + Child = buttonFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(3), + }; + + addButton(rankedPlayScreen?.ActiveSubScreen is not EndedScreen ? "Give up" : "Exit", FontAwesome.Solid.SignOutAlt, () => rankedPlayScreen?.Exit()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(this)); + } + + private void addButton(LocalisableString text, IconUsage? icon, Action? action, Color4? colour = null) + { + var button = new OptionButton + { + Text = text, + Icon = icon ?? new IconUsage(), + BackgroundColour = colourProvider.Background3, + TextColour = colour, + Action = () => + { + Scheduler.AddDelayed(Hide, 50); + action?.Invoke(); + }, + }; + + buttonFlow.Add(button); + } + + private partial class OptionButton : OsuButton + { + public IconUsage Icon { get; init; } + public Color4? TextColour { get; init; } + + public OptionButton() + { + Size = new Vector2(265, 50); + } + + [BackgroundDependencyLoader] + private void load() + { + SpriteText.Colour = TextColour ?? Color4.White; + Content.CornerRadius = 10; + + Add(new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(17), + X = 15, + Icon = Icon, + Colour = TextColour ?? Color4.White, + }); + } + + protected override SpriteText CreateText() => new OsuSpriteText + { + Depth = -1, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + X = 40 + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs new file mode 100644 index 000000000000..43e8cbf270e2 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.HandCard.cs @@ -0,0 +1,113 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + public abstract partial class HandOfCards + { + public partial class HandCard : CompositeDrawable + { + public float LayoutWidth => DrawWidth * (State.Hovered ? hover_scale : 1); + + private readonly Bindable state = new Bindable(); + + public RankedPlayCardState State + { + get => state.Value; + set => state.Value = value; + } + + public bool Selected + { + get => State.Selected; + set => State = State with { Selected = value }; + } + + public bool CardHovered + { + get => State.Hovered; + set => State = State with { Hovered = value }; + } + + public bool CardPressed + { + get => State.Pressed; + set => State = State with { Pressed = value }; + } + + [Resolved] + private HandOfCards handOfCards { get; set; } = null!; + + public readonly RankedPlayCard Card; + + public RankedPlayCardWithPlaylistItem Item => Card.Item; + + public HandCard(RankedPlayCard card) + { + Size = card.DrawSize; + + card.Anchor = Anchor.Centre; + card.Origin = Anchor.Centre; + card.Position = Vector2.Zero; + card.Rotation = 0; + card.Scale = Vector2.One; + + AddInternal(Card = card); + + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + state.BindValueChanged(OnStateChanged, true); + } + + protected virtual void OnStateChanged(ValueChangedEvent state) + { + handOfCards.OnCardStateChanged(this, state.NewValue); + + Card.ShowSelectionOutline = state.NewValue.Selected; + + switch (state.NewValue.Pressed, state.OldValue.Pressed) + { + case (true, false): + Card.ScaleTo(0.95f, 300, Easing.OutExpo); + break; + + case (false, true): + Card.ScaleTo(1f, 400, Easing.OutElasticHalf); + break; + } + } + + public RankedPlayCard Detach() + { + Card.ShowSelectionOutline = false; + Card.Elevation = 0; + + RemoveInternal(Card, false); + + return Card; + } + + protected override void Update() + { + base.Update(); + + Card.Elevation = float.Lerp(CardHovered ? 1 : 0, Card.Elevation, (float)Math.Exp(-0.03f * Time.Elapsed)); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs new file mode 100644 index 000000000000..935d8fb3b8a9 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandOfCards.cs @@ -0,0 +1,255 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + /// + /// Drawable that layouts cards as if held in a player's hands. + /// + [Cached] + public abstract partial class HandOfCards : CompositeDrawable + { + private const float hover_scale = 1.2f; + + public IEnumerable Cards => cardContainer.Children; + + /// + /// How far a card slides upwards when hovered. + /// Used for making sure a card moves entirely into frame when the hand is partially off-screen. + /// + public float HoverYOffset = 15; + + /// + /// If true, card layout will be flipped on both axes for a card hand placed at the top edge of the screen, while keeping the cards upright. + /// Used for . + /// + protected virtual bool Flipped => false; + + private readonly Container cardContainer; + + private readonly Dictionary cardLookup = new Dictionary(); + + protected HandOfCards() + { + AddInternal(cardContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }); + } + + protected override void Update() + { + base.Update(); + + if (!layoutBacking.IsValid) + { + updateLayout(); + layoutBacking.Validate(); + } + } + + protected bool Contracted { get; private set; } + + /// + /// Contracts all cards towards the bottom (or top when ). + /// Cards will no longer get layouted after this method is called. + /// + public void Contract() + { + Contracted = true; + + double delay = 0; + + foreach (var card in cardContainer) + { + card.Delay(delay) + .MoveTo(new Vector2(0, Flipped ? -220 : 220), 400, Easing.OutExpo) + .RotateTo(0, 400, Easing.OutExpo) + .ScaleTo(1, 400, Easing.OutExpo); + + delay += 50; + } + } + + private Anchor cardAnchor => Flipped ? Anchor.TopCentre : Anchor.BottomCentre; + + public void AddCard(RankedPlayCardWithPlaylistItem item, Action? setupAction = null) => AddCard(new RankedPlayCard(item), setupAction); + + public void AddCard(RankedPlayCard card, Action? setupAction = null) + { + if (cardLookup.ContainsKey(card.Item.Card)) + return; + + var drawable = CreateHandCard(card); + drawable.Anchor = drawable.Origin = cardAnchor; + + cardLookup[card.Item.Card] = drawable; + + cardContainer.Add(drawable); + layoutBacking.Invalidate(); + + setupAction?.Invoke(drawable); + } + + public void Clear() => cardContainer.Clear(); + + public bool RemoveCard(RankedPlayCardWithPlaylistItem item) + { + if (!cardLookup.Remove(item.Card, out var drawable)) + return false; + + cardContainer.Remove(drawable, true); + layoutBacking.Invalidate(); + return false; + } + + /// + /// Removes a card and detaches it's contained card so it can be attached to a new card facade. + /// + /// Item to remove the card for + /// Contained + /// of the removed card + /// Whether a card was found for the provided item + public bool RemoveCard(RankedPlayCardWithPlaylistItem item, [MaybeNullWhen(false)] out RankedPlayCard card, out Quad screenSpaceDrawQuad) + { + if (!cardLookup.Remove(item.Card, out var drawable)) + { + card = null; + screenSpaceDrawQuad = default; + return false; + } + + screenSpaceDrawQuad = drawable.ScreenSpaceDrawQuad; + card = drawable.Detach(); + + cardContainer.Remove(drawable, true); + layoutBacking.Invalidate(); + + return true; + } + + protected virtual HandCard CreateHandCard(RankedPlayCard card) => new HandCard(card); + + protected virtual void OnCardStateChanged(HandCard card, RankedPlayCardState state) + { + InvalidateLayout(); + + // hovered state can be caused by keyboard focus, in which case we have to clean up after the other cards manually + if (state.Hovered) + { + foreach (var c in cardContainer) + { + if (c != card) + c.CardHovered = false; + } + } + } + + #region Layout + + private readonly Cached layoutBacking = new Cached(); + + protected void InvalidateLayout() => layoutBacking.Invalidate(); + + public void UpdateLayout(double stagger = 0) + { + updateLayout(stagger); + layoutBacking.Validate(); + } + + private void updateLayout(double stagger = 0) + { + if (Contracted) + return; + + const float spacing = -20; + + float totalWidth = cardContainer.Sum(it => it.LayoutWidth + spacing) - spacing; + + float x = -totalWidth / 2; + + const int no_card_hovered = -1; + int hoverIndex = no_card_hovered; + + for (int i = 0; i < cardContainer.Count; i++) + { + if (cardContainer[i].CardHovered) + { + hoverIndex = i; + break; + } + } + + double delay = 0; + + for (int i = 0; i < cardContainer.Count; i++) + { + var child = cardContainer[i]; + + x += child.LayoutWidth / 2; + + float yOffset = 0; + + var position = new Vector2(x, MathF.Pow(MathF.Abs(x / 250), 2) * 20 - 10); + + if (hoverIndex != no_card_hovered && cardContainer.Children.Count > 1) + { + int distance = Math.Abs(i - hoverIndex); + int direction = Math.Sign(i - hoverIndex); + + position.X += direction switch + { + 0 => 0, + + // special case for the left card when there's only 2 cards + // too much offset looks kinda odd here so it's reduced + < 0 when cardContainer.Count == 2 => -3, + + < 0 => -10 / MathF.Pow(distance, 3), + + // cards right to the hovered card have a higher offset because they are partially + // covering the cards to their left + > 0 => 20 / MathF.Pow(distance, 2), + }; + } + + if (child.CardHovered) + yOffset = -HoverYOffset; + + float rotation = x * 0.03f; + + float angle = MathHelper.DegreesToRadians(rotation + 90); + + position += new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * yOffset; + + position *= Flipped ? -1 : 1; + + child + .Delay(delay) + .MoveTo(position, 300, Easing.OutExpo) + .RotateTo(rotation, 300, Easing.OutExpo) + .ScaleTo(child.CardHovered ? hover_scale : 1f, 400, Easing.OutElasticQuarter); + + x += child.LayoutWidth / 2 + spacing; + + delay += stagger; + } + } + + #endregion + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs new file mode 100644 index 000000000000..e7c6fe0ec8ad --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayPlayer.cs @@ -0,0 +1,72 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.RankedPlay; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + public partial class HandReplayPlayer : Component + { + /// + /// Maximum amount of frames that can get queued up at the same time + /// + public int MaxQueuedFrames { get; set; } = 20; + + private readonly int userId; + private readonly OpponentHandOfCards handOfCards; + + private int queuedFrames; + private double? lastPlayback; + + public HandReplayPlayer(int userId, OpponentHandOfCards handOfCards) + { + this.userId = userId; + this.handOfCards = handOfCards; + } + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.MatchEvent += onMatchEvent; + } + + private void onMatchEvent(MatchServerEvent e) + { + if (e is not RankedPlayCardHandReplayEvent replayEvent || replayEvent.UserId != userId) + return; + + foreach (var frame in replayEvent.Frames) + { + if (queuedFrames >= MaxQueuedFrames) + return; + + queuedFrames++; + + double delay = Math.Max(lastPlayback != null ? lastPlayback.Value + frame.Delay - Time.Current : 0, 0); + lastPlayback = Time.Current + delay; + + Scheduler.AddDelayed(() => + { + queuedFrames--; + + handOfCards.SetState(frame.Cards); + }, delay); + } + } + + protected override void Dispose(bool isDisposing) + { + client.MatchEvent -= onMatchEvent; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs new file mode 100644 index 000000000000..9c4bf1d1cbc2 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandReplayRecorder.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.RankedPlay; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + public partial class HandReplayRecorder : Component + { + /// + /// Interval at which buffered frames get collected and emitted + /// + public double FlushInterval { get; init; } = 1000; + + /// + /// Minimum interval between individual replay frames + /// + public double RecordInterval { get; init; } = 25; + + /// + /// Max amount of frames to collect per + /// + public int MaxBufferSize = 20; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private readonly PlayerHandOfCards handOfCards; + + private readonly List buffer = new List(); + private bool hasChanges; + private double? lastFrameTime; + + public HandReplayRecorder(PlayerHandOfCards handOfCards) + { + this.handOfCards = handOfCards; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Scheduler.AddDelayed(recordFrame, RecordInterval, true); + Scheduler.AddDelayed(tryFlush, FlushInterval, true); + + handOfCards.StateChanged += onHandOfCardsStateChanged; + } + + private void onHandOfCardsStateChanged() => hasChanges = true; + + private void recordFrame() + { + if (!hasChanges || buffer.Count >= MaxBufferSize) + return; + + double delay = lastFrameTime != null ? Time.Current - lastFrameTime.Value : 0; + + buffer.Add(new RankedPlayCardHandReplayFrame + { + Delay = delay, + Cards = handOfCards.State, + }); + + lastFrameTime = Time.Current; + hasChanges = false; + } + + private void tryFlush() + { + if (buffer.Count == 0) + return; + + var frames = compress(buffer).ToArray(); + buffer.Clear(); + + if (frames.Length > 0) + Flush(frames); + } + + /// + /// Compresses a list of s by only keeping values that have changed between each frame + /// + private IEnumerable compress(IReadOnlyList frames) + { + if (frames.Count == 0) + yield break; + + // The first frame always contains the full state since the replay player may drop frames starting from the end for each message. + yield return frames[0]; + + var lastFrame = frames[0]; + + foreach (var frame in frames.Skip(1)) + { + yield return frame.RelativeTo(lastFrame); + + lastFrame = frame; + } + } + + protected virtual void Flush(RankedPlayCardHandReplayFrame[] frames) + { + if (frames.Length == 0) + return; + + client.SendMatchRequest(new RankedPlayCardHandReplayRequest + { + Frames = frames, + }); + } + + protected override void Dispose(bool isDisposing) + { + handOfCards.StateChanged -= onHandOfCardsStateChanged; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandSelectionMode.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandSelectionMode.cs new file mode 100644 index 000000000000..4a8091331489 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/HandSelectionMode.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. + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + public enum HandSelectionMode + { + Disabled, + Single, + Multiple, + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs new file mode 100644 index 000000000000..e2e27d820d2a --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/OpponentHandOfCards.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Game.Online.RankedPlay; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + /// + /// Card hand representing the opponent's current hand, intended to be placed at the top edge of the screen. + /// + public partial class OpponentHandOfCards : HandOfCards + { + protected override bool Flipped => true; + + public void SetState(Dictionary state) + { + foreach (var card in Cards) + { + if (!state.TryGetValue(card.Item.Card.ID, out var cardState)) + continue; + + card.State = cardState; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs new file mode 100644 index 000000000000..c4021d7df9d4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.PlayerHandCard.cs @@ -0,0 +1,170 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + public partial class PlayerHandOfCards + { + public partial class PlayerHandCard : HandCard + { + private Action? playAction; + + public Action? PlayAction + { + get => playAction; + set + { + playAction = value; + PlayButton.Action = value; + updatePlayButtonVisibility(); + } + } + + public required Action Clicked; + + public required IBindable AllowSelection; + + private readonly Drawable cardInputArea; + private readonly Drawable fullInputArea; + + public readonly ShearedButton PlayButton; + + public PlayerHandCard(RankedPlayCard card) + : base(card) + { + AddRangeInternal(new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(-10) + { + // card moves upwards on hover which can produce jitter if the hitbox doesn't extend all the way to the bottom of the screen + Bottom = -50 + }, + Child = cardInputArea = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = -40 }, + Child = fullInputArea = new Container + { + RelativeSizeAxes = Axes.Both, + Child = PlayButton = new ShearedButton + { + Name = "Play Button", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(90, 30), + Text = "Play", + TextSize = 14, + LighterColour = Colour4.FromHex("87D8FA"), + DarkerColour = Colour4.FromHex("72D5FF") + } + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddInternal(new HoverSounds()); + } + + protected override void OnStateChanged(ValueChangedEvent state) + { + base.OnStateChanged(state); + updatePlayButtonVisibility(); + } + + private void updatePlayButtonVisibility() + { + PlayButton.Alpha = PlayButton.Action != null && Selected ? 1 : 0; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (PlayButton.Alpha > 0) + return fullInputArea.ReceivePositionalInputAt(screenSpacePos); + + // input events are handled for an area that's slightly larger than the actual card so the cursor always hovers a card when moving over a gap between two cards + return cardInputArea.ReceivePositionalInputAt(screenSpacePos); + } + + protected override bool OnHover(HoverEvent e) + { + CardHovered = true; + + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + CardHovered = false; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left && AllowSelection.Value) + { + CardPressed = true; + + return true; + } + + return false; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button == MouseButton.Left) + CardPressed = false; + } + + protected override bool OnClick(ClickEvent e) + { + if (!AllowSelection.Value) + return false; + + Clicked(this); + + return true; + } + + public override bool AcceptsFocus => true; + + public override bool ChangeFocusOnClick => false; + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + CardHovered = true; + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + CardHovered = false; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs new file mode 100644 index 000000000000..7ec73b76627c --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Hand/PlayerHandOfCards.cs @@ -0,0 +1,219 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; +using osu.Game.Audio; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osuTK.Input; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand +{ + /// + /// Card hand representing the player's current hand, intended to be placed at the bottom edge of the screen. + /// This version of the card hand reacts to player inputs like hovering a card. + /// + public partial class PlayerHandOfCards : HandOfCards + { + /// + /// Fired if any card is selected or deselected + /// + public event Action? SelectionChanged; + + /// + /// Fired if a card's has changed + /// + public event Action? StateChanged; + + private HandSelectionMode selectionMode; + + /// + /// Current selection mode. + /// + /// + /// will disable some of the card's mouse interactions. + /// + public HandSelectionMode SelectionMode + { + get => selectionMode; + set + { + selectionMode = value; + allowSelection.Value = value != HandSelectionMode.Disabled; + + if (value == HandSelectionMode.Disabled) + { + foreach (var card in Cards) + card.Selected = false; + } + } + } + + private Action? playCardAction; + + /// + /// When set to non-null, displays a "Play" button on the selected card that invokes this action. + /// + public Action? PlayCardAction + { + get => playCardAction; + set + { + playCardAction = value; + + foreach (var card in Cards.OfType()) + card.PlayAction = value; + } + } + + private IEnumerable selection => Cards.OfType().Where(it => it.Selected); + + /// + /// Currently selected cards. + /// + public IEnumerable Selection => selection.Select(it => it.Card.Item); + + private readonly BindableBool allowSelection = new BindableBool(); + + private const int select_samples = 1; + private const int deselect_samples = 2; + + private Sample?[]? cardSelectSamples; + private Sample?[]? cardDeselectSamples; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + cardSelectSamples = new Sample?[select_samples]; + for (int i = 0; i < select_samples; i++) + cardSelectSamples[i] = audio.Samples.Get(@$"Multiplayer/Matchmaking/Ranked/card-select-{i + 1}"); + + cardDeselectSamples = new Sample?[deselect_samples]; + for (int i = 0; i < deselect_samples; i++) + cardDeselectSamples[i] = audio.Samples.Get(@$"Multiplayer/Matchmaking/Ranked/card-deselect-{i + 1}"); + } + + protected override HandCard CreateHandCard(RankedPlayCard card) => new PlayerHandCard(card) + { + Clicked = cardClicked, + AllowSelection = allowSelection.GetBoundCopy(), + PlayAction = PlayCardAction, + }; + + private void cardClicked(PlayerHandCard card) + { + if (selectionMode == HandSelectionMode.Disabled) + return; + + try + { + if (selectionMode == HandSelectionMode.Single) + { + // only play feedback SFX if the selected card has changed + if (!card.Selected) + SamplePlaybackHelper.PlayWithRandomPitch(cardSelectSamples); + + foreach (var c in Cards) + { + ((PlayerHandCard)c).Selected = c == card; + } + + return; + } + + card.Selected = !card.Selected; + + SamplePlaybackHelper.PlayWithRandomPitch(card.Selected ? cardSelectSamples : cardDeselectSamples); + } + finally + { + SelectionChanged?.Invoke(); + } + } + + protected override void OnCardStateChanged(HandCard card, RankedPlayCardState state) + { + StateChanged?.Invoke(); + + base.OnCardStateChanged(card, state); + } + + public Dictionary State => Cards.Select(static card => new KeyValuePair(card.Item.Card.ID, card.State)).ToDictionary(); + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat || Contracted) + return false; + + switch (e.Key) + { + case >= Key.Number1 and <= Key.Number9: + focusCard(e.Key - Key.Number1); + return true; + + case Key.Space: + if (selectionMode == HandSelectionMode.Disabled) + return false; + + if (Cards.FirstOrDefault(it => it.HasFocus) is not PlayerHandCard card) + return false; + + if (card.Selected) + card.PlayButton.TriggerClick(); + else + card.TriggerClick(); + + return true; + + case Key.Left: + moveCardFocus(-1); + return true; + + case Key.Right: + moveCardFocus(1); + return true; + } + + return base.OnKeyDown(e); + } + + private void moveCardFocus(int direction) + { + int currentIndex = Cards.ToList().FindIndex(c => c.HasFocus); + + // default behaviour is to start from either end of the cards if no card is focused currently + // in single-selection mode we can however use the current selection as a fallback index if there's no focus + if (selectionMode == HandSelectionMode.Single && currentIndex == -1) + currentIndex = Cards.ToList().FindIndex(c => c.Selected); + + int newIndex = currentIndex + direction; + + if (newIndex < 0) + newIndex = Cards.Count() - 1; + else if (newIndex >= Cards.Count()) + newIndex = 0; + + focusCard(newIndex); + } + + private void focusCard(int index) + { + var card = Cards.ElementAtOrDefault(index); + + if (card == null) + return; + + GetContainingFocusManager()?.ChangeFocus(card); + + if (SelectionMode == HandSelectionMode.Single && !card.Selected) + card.TriggerClick(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/CoverReveal.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/CoverReveal.cs new file mode 100644 index 000000000000..fe25ab85016b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/CoverReveal.cs @@ -0,0 +1,119 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Game.Graphics.Backgrounds; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public partial class CoverReveal : CompositeDrawable + { + private readonly Container content; + private readonly Box bottomLayer; + private readonly Box middleLayer; + private readonly Box topLayer; + private readonly TrianglesV2 triangles; + + public CoverReveal(RankedPlayColourScheme colourScheme) + { + Padding = new MarginPadding { Horizontal = 100 }; + Masking = true; + + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = -50 }, + Children = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + ClampAxes = Axes.None, + Colour = ColourInfo.GradientHorizontal(Color4.White, Color4.White.Opacity(0)), + }, + bottomLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = new Vector2(-0.1f, 0), + Colour = ColourInfo.GradientVertical(colourScheme.PrimaryDarkest, colourScheme.PrimaryDarkest.Opacity(0)), + Alpha = 0.5f, + }, + middleLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = new Vector2(0.1f, 0), + Colour = ColourInfo.GradientVertical(colourScheme.PrimaryDarker, colourScheme.Primary.Opacity(0.5f)), + Alpha = 0.75f, + }, + topLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourScheme.Primary, + Shear = new Vector2(-0.1f, 0) + }, + ] + }, + ] + }; + } + + protected override void Update() + { + base.Update(); + + Padding = new MarginPadding + { + Horizontal = -DrawHeight * 0.25f / 2 + }; + } + + public void Play() + { + content.MoveToX(50) + .MoveToX(-50, 4000); + + triangles.MoveToX(-0.75f, 3500, new CubicBezierEasingFunction(0.05, 1, 0, 1)) + .FadeOut(2000); + + topLayer.ResizeWidthTo(0.0f, 2800, new CubicBezierEasingFunction(0.05, 1, 0, 1)) + .TransformTo(nameof(Shear), new Vector2(0.1f, 0), 2800, Easing.OutPow10) + .Then() + .ResizeWidthTo(0, 500, Easing.InQuart); + + middleLayer + .Delay(50) + .ResizeWidthTo(0.15f, 2900, new CubicBezierEasingFunction(0.05, 1, 0, 1)) + .TransformTo(nameof(Shear), new Vector2(-0.15f, 0), 2900, Easing.OutPow10) + .Then() + .ResizeWidthTo(0, 500, Easing.InQuart) + .TransformTo(nameof(Shear), new Vector2(-0.25f, 0), 500, Easing.InCubic); + + bottomLayer + .Delay(100) + .ResizeWidthTo(0.2f, 3000, new CubicBezierEasingFunction(0.05, 1, 0, 1)) + .TransformTo(nameof(Shear), new Vector2(0.3f, 0), 3000, Easing.OutPow10) + .Then() + .ResizeWidthTo(0, 500, Easing.InQuart) + .TransformTo(nameof(Shear), new Vector2(0.5f, 0), 500, Easing.InCubic); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs new file mode 100644 index 000000000000..7cfed62015c5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/IntroScreen.cs @@ -0,0 +1,128 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Overlays; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public partial class IntroScreen : RankedPlaySubScreen + { + protected override LocalisableString StageHeading => string.Empty; + protected override LocalisableString StageCaption => string.Empty; + + public IntroScreen() + { + CornerPieceVisibility.Value = Visibility.Hidden; + CountdownVisibility.Value = Visibility.Hidden; + } + + [Resolved] + private UserLookupCache userLookupCache { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MusicController? musicController { get; set; } + + private Sample? windupSample; + private Sample? impactSample; + private Sample? swooshSample; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + windupSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/vs-windup"); + impactSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/vs-impact"); + swooshSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/vs-swoosh"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + loadUsers().FireAndForget(); + } + + private async Task loadUsers() + { + var roomState = ((RankedPlayRoomState)Client.Room!.MatchState!); + + int[] userIds = roomState.Users.Keys.ToArray(); + + var users = await userLookupCache.GetUsersAsync(userIds).ConfigureAwait(false); + + var player = users.OfType().First(it => it.Id == api.LocalUser.Value.Id); + var opponent = users.OfType().First(it => it.Id != api.LocalUser.Value.Id); + + int playerRating = roomState.Users[player.Id].Rating; + int opponentRating = roomState.Users[opponent.Id].Rating; + + Schedule(() => PlayIntroSequence( + new UserWithRating(player, playerRating), + new UserWithRating(opponent, opponentRating), + roomState.StarRating + )); + } + + private StarRatingSequence? starRatingAnimation; + + private IDisposable? duckOperation; + + public void PlayIntroSequence(UserWithRating player, UserWithRating opponent, double starRating) + { + double delay = 0; + + var vsScreen = new VsSequence(player, opponent); + + starRatingAnimation = new StarRatingSequence(); + + AddRangeInternal([vsScreen, starRatingAnimation]); + + vsScreen.Play(ref delay, out double impactDelay); + + duckOperation = musicController?.Duck(); + + if (windupSample != null) + { + Scheduler.AddDelayed(() => windupSample?.Play(), impactDelay - windupSample.Length); + Scheduler.AddDelayed(() => impactSample?.Play(), impactDelay); + Scheduler.AddDelayed(() => swooshSample?.Play(), impactDelay + 3200); + } + + Scheduler.AddDelayed(() => CornerPieceVisibility.Value = Visibility.Visible, delay); + + starRatingAnimation.Play(ref delay, (float)starRating); + } + + public override void OnExiting(RankedPlaySubScreen? next) + { + starRatingAnimation?.PopOut(); + + duckOperation?.Dispose(); + + this.Delay(500).FadeOut(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + duckOperation?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/StarRatingSequence.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/StarRatingSequence.cs new file mode 100644 index 000000000000..d4f64bdfc727 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/StarRatingSequence.cs @@ -0,0 +1,341 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Transforms; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public partial class StarRatingSequence : CompositeDrawable + { + private Container bars = null!; + private Container starContainer = null!; + private Container centerContainer = null!; + private OsuSpriteText title = null!; + private OsuSpriteText creatingMapPool = null!; + private OsuSpriteText explainer = null!; + + private Sample? tickSample; + private Sample? tickFinalSample; + private Sample? ratingFoundSample; + private Sample? noticeSample; + + private float lastTickStdDev; + + [BackgroundDependencyLoader] + private void load(OsuColour colour, AudioManager audio) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Alpha = 0; + + Padding = new MarginPadding { Horizontal = 100 }; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = + [ + title = new OsuSpriteText + { + Text = "Finding Match Rating...", + Font = OsuFont.Style.Title, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + creatingMapPool = new OsuSpriteText + { + Text = "Creating a mappool...", + Font = OsuFont.Style.Heading1, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Bottom = 30 }, + Alpha = 0, + AlwaysPresent = true, + }, + centerContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 90, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = + [ + bars = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 30 }, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + ] + }, + starContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 20, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + explainer = new OsuSpriteText + { + Text = "There’s still a chance that you get maps outside of the selected match rating!", + Font = OsuFont.Style.Heading2, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Top = 20 }, + Alpha = 0, + AlwaysPresent = true, + } + ], + }; + + for (int i = 0; i < 100; i++) + { + float difficulty = i / 10f; + + bars.Add(new Bar + { + StarRating = difficulty, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = difficulty / 10f, + Width = 0.0075f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Colour = colour.ForStarDifficulty(Math.Max(difficulty, 0.1)), + Height = 0 + }); + + if (i > 0 && i % 10 == 0) + { + var starRatingDisplay = new StarRatingDisplay(new StarDifficulty(difficulty, 0), StarRatingDisplaySize.Small) + { + RelativePositionAxes = Axes.X, + X = difficulty / 10f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Scale = new Vector2(0) + }; + + starContainer.Add(starRatingDisplay); + } + } + + tickSample = audio.Samples.Get("Multiplayer/Matchmaking/Ranked/star-rating-tick"); + tickFinalSample = audio.Samples.Get("Multiplayer/Matchmaking/Ranked/star-rating-tick-final"); + ratingFoundSample = audio.Samples.Get("Multiplayer/Matchmaking/Ranked/star-rating-found"); + noticeSample = audio.Samples.Get("Multiplayer/Matchmaking/Ranked/star-rating-notice"); + } + + private float starRating { get; set; } = 5; + + private float amplitude { get; set; } = 0; + + private float stdDev { get; set; } = 6; + + private bool animateGaussianCurve; + + public void Play(ref double delay, float starRating) + { + using (BeginDelayedSequence(delay)) + { + popIn(); + } + + delay += 500; + + using (BeginDelayedSequence(delay)) + { + Schedule(() => animateGaussianCurve = true); + + this.TransformTo(nameof(starRating), starRating < 5 ? starRating + 4 : starRating - 4); + this.TransformTo(nameof(starRating), starRating, 4000, new CubicBezierEasingFunction(easeIn: 0.3, easeOut: 0.5)); + this.TransformTo(nameof(amplitude), 1f, 4000, new CubicBezierEasingFunction(easeIn: 0.1, easeOut: 0.8)); + this.TransformTo(nameof(stdDev), 0.3f, 4500, new CubicBezierEasingFunction(easeIn: 0.2, easeOut: 0.7)); + } + + delay += 5000; + + using (BeginDelayedSequence(delay)) + { + Schedule(() => + { + animateGaussianCurve = false; + + ratingFoundSample?.Play(); + + var container = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + Anchor = Anchor.TopLeft, + Origin = Anchor.BottomCentre, + AutoSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = starRating * 0.1f, + Y = 24, + Colour = Color4Extensions.FromHex("#FFE280"), + Spacing = new Vector2(4, 0), + Children = + [ + new OsuSpriteText + { + Text = FormattableString.Invariant($"~{starRating:F2}"), + Font = OsuFont.GetFont(size: 24, weight: FontWeight.Bold), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new SpriteIcon + { + Icon = FontAwesome.Solid.Star, + Size = new Vector2(19), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + ] + }; + + centerContainer.Add(container); + + container.FadeInFromZero(200) + .ScaleTo(0) + .ScaleTo(1, 400, Easing.OutElasticQuarter); + + title.Text = "Match rating found!"; + + creatingMapPool.FadeIn(100); + explainer.Delay(1050).FadeIn(100); + Scheduler.AddDelayed(() => + { + noticeSample?.Play(); + }, 1050); + }); + } + } + + private void popIn() + { + this.FadeIn(200); + + foreach (var bar in bars) + { + double delay = Math.Abs(bar.StarRating - 5) * 50; + + bar.Delay(delay) + .ResizeHeightTo(0.1f, 300, Easing.OutExpo); + } + + foreach (var drawable in starContainer) + { + double delay = Math.Abs((drawable.X * 10) - 5) * 50 + 100; + + drawable.Delay(delay) + .ScaleTo(0.8f, 400, Easing.OutElasticQuarter); + } + } + + public void PopOut() + { + foreach (var bar in bars) + { + double delay = Math.Abs(bar.StarRating - 5) * 50; + + bar.Delay(delay) + .ResizeHeightTo(0f, 300, Easing.OutExpo); + } + + foreach (var drawable in starContainer) + { + double delay = Math.Abs((drawable.X * 10) - 5) * 50 + 100; + + drawable.Delay(delay) + .ScaleTo(0f, 400, Easing.OutElasticQuarter); + } + + this.FadeOut(150); + } + + protected override void Update() + { + base.Update(); + + if (!animateGaussianCurve) + return; + + foreach (var bar in bars) + { + float value = gaussianCurve(bar.StarRating, 1f, starRating, stdDev); + + bar.Height = float.Lerp(0.1f, 1f, value * amplitude); + + float targetAlpha = float.Clamp(0.35f + value * 20f, 0.35f, 1); + + bar.Alpha = float.Lerp(targetAlpha, bar.Alpha, (float)Math.Exp(-0.01f * Time.Elapsed)); + } + + foreach (var child in starContainer) + { + float value = gaussianCurve(child.X * 10f, 1f, starRating, stdDev); + + float targetAlpha = float.Clamp(0.35f + value * 20f, 0.35f, 1); + + child.Alpha = float.Lerp(targetAlpha, child.Alpha, (float)Math.Exp(-0.01f * Time.Elapsed)); + } + + static float gaussianCurve(float x, float amplitude, float center, float stdev) + { + float v1 = x - center; + float v2 = (v1 * v1) / (2 * (stdev * stdev)); + return amplitude * MathF.Exp(-v2); + } + + if (Math.Abs(lastTickStdDev - stdDev) <= 0.075) return; + + var tickChannel = tickSample!.GetChannel(); + tickChannel.Frequency.Value = 1 + amplitude * 0.3f; + tickChannel.Volume.Value = 0.5 + amplitude * 0.5; + tickChannel.Play(); + + if (stdDev < 1) + { + var tickFinalChannel = tickFinalSample!.GetChannel(); + tickFinalChannel.Frequency.Value = 1 + amplitude * 0.3f; + tickFinalChannel.Volume.Value = 0.1f + amplitude * 0.4f; + tickFinalChannel.Play(); + } + + lastTickStdDev = stdDev; + } + + private partial class Bar : CircularContainer + { + public required float StarRating; + + public Bar() + { + Masking = true; + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/UserWithRating.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/UserWithRating.cs new file mode 100644 index 000000000000..584dea0a6ba4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/UserWithRating.cs @@ -0,0 +1,9 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public record UserWithRating(APIUser User, int Rating); +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/VsSequence.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/VsSequence.cs new file mode 100644 index 000000000000..b845c0031050 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Intro/VsSequence.cs @@ -0,0 +1,325 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.Transforms; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro +{ + public partial class VsSequence(UserWithRating player, UserWithRating opponent) : CompositeDrawable + { + private Drawable playerBackground = null!; + private Drawable opponentBackground = null!; + private Box flash = null!; + private Drawable playerDisplay = null!; + private Drawable opponentDisplay = null!; + private CoverReveal opponentCoverReveal = null!; + private CoverReveal playerCoverReveal = null!; + private VsText vsText = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = -100 }, + Children = + [ + playerBackground = new DelayedLoadWrapper(() => new PlayerCover(player.User) + { + RelativeSizeAxes = Axes.Both, + }, timeBeforeLoad: 0) + { + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.5f), Color4.White.Opacity(0.85f)), + Alpha = 0, + AlwaysPresent = true, + }, + opponentBackground = new DelayedLoadWrapper(() => new PlayerCover(opponent.User) + { + RelativeSizeAxes = Axes.Both, + }, timeBeforeLoad: 0) + { + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.85f), Color4.White.Opacity(0.5f)), + Alpha = 0, + AlwaysPresent = true, + }, + ], + }, + playerDisplay = new UserDisplay(player, Anchor.BottomLeft) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(70), + Alpha = 0, + AlwaysPresent = true, + }, + opponentDisplay = new UserDisplay(opponent, Anchor.BottomRight) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding(70), + Alpha = 0, + AlwaysPresent = true, + }, + opponentCoverReveal = new CoverReveal(RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new Vector2(-1, 1), + Alpha = 0, + }, + playerCoverReveal = new CoverReveal(RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Alpha = 0, + }, + flash = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + vsText = new VsText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + ]; + } + + public void Play(ref double delay, out double impactDelay) + { + using (BeginDelayedSequence(delay)) + { + this.FadeInFromZero(500); + + vsText.AnimateEntry(1000, Easing.OutExpo); + vsText.ScaleTo(0.4f, 1300, Easing.OutExpo); + } + + delay += 850; + + impactDelay = delay; + + using (BeginDelayedSequence(delay)) + { + flash.FadeOutFromOne(500, Easing.Out); + + vsText.RevealText(); + + playerCoverReveal.FadeIn(); + opponentCoverReveal.FadeIn(); + + playerCoverReveal.Play(); + opponentCoverReveal.Play(); + + playerBackground + .FadeIn() + .MoveToX(-40) + .MoveToX(40, 3000, new CubicBezierEasingFunction(0, 0.3, 0, 0.65)) + .Then() + .MoveToX(100, 500, new CubicBezierEasingFunction(0.8, 0.05, 0.8, 0.8)); + + opponentBackground + .FadeIn() + .MoveToX(40) + .MoveToX(-40, 3000, new CubicBezierEasingFunction(0, 0.3, 0, 0.65)) + .Then() + .MoveToX(-100, 500, new CubicBezierEasingFunction(0.8, 0.05, 0.8, 0.8)); + + playerDisplay + .FadeIn() + .MoveToX(-400) + .MoveToX(-100, 3000, new CubicBezierEasingFunction(0, 0.3, 0, 0.75)) + .Then() + .MoveToX(800, 500, new CubicBezierEasingFunction(0.8, 0.05, 0.8, 0.8)); + + opponentDisplay + .FadeIn() + .MoveToX(400) + .MoveToX(100, 3000, new CubicBezierEasingFunction(0, 0.6, 0, 0.75)) + .Then() + .MoveToX(-800, 500, new CubicBezierEasingFunction(0.8, 0.05, 0.8, 0.8)); + + vsText.Delay(3200) + .ScaleTo(0.25f, 400, Easing.InCubic); + + this.Delay(3200).FadeOut(300).Expire(); + } + + delay += 3350; + } + + private partial class UserDisplay : CompositeDrawable + { + public UserDisplay(UserWithRating user, Anchor contentAnchor) + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + Children = + [ + new CircularContainer + { + Size = new Vector2(96), + Masking = true, + Anchor = contentAnchor, + Origin = contentAnchor, + Child = new DelayedLoadWrapper(() => new DrawableAvatar(user.User) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, timeBeforeLoad: 0) + { + RelativeSizeAxes = Axes.Both, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = contentAnchor, + Origin = contentAnchor, + Padding = new MarginPadding { Vertical = 10 }, + Children = + [ + new OsuSpriteText + { + Text = FormattableString.Invariant($"Rating: {user.Rating:N0}"), + Alpha = 0.8f, + Font = OsuFont.Style.Title.With(size: 26), + Anchor = contentAnchor, + Origin = contentAnchor, + }, + new OsuSpriteText + { + Text = user.User.Username, + Font = OsuFont.Style.Title.With(size: 40, weight: FontWeight.SemiBold), + Anchor = contentAnchor, + Origin = contentAnchor, + }, + ] + } + ] + }; + } + } + + [LongRunningLoad] + public partial class PlayerCover : CompositeDrawable + { + private readonly APIUser user; + + public PlayerCover(APIUser user) + { + this.user = user; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + Masking = true; + + AddInternal(new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(user.CoverUrl), + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.FadeInFromZero(250); + } + } + + private partial class VsText : CompositeDrawable + { + private Sprite vsText = null!; + private LogoAnimation logoAnimation = null!; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AutoSizeAxes = Axes.Both; + + InternalChildren = + [ + vsText = new Sprite + { + Texture = textures.Get("Online/RankedPlay/vs"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + }, + logoAnimation = new LogoAnimation + { + Texture = textures.Get("Online/RankedPlay/vs-animation"), + }, + ]; + } + + public void AnimateEntry(double duration, Easing easing) + { + logoAnimation.TransformTo(nameof(logoAnimation.AnimationProgress), 1f, duration, easing); + } + + public void RevealText() + { + vsText.FadeIn(); + logoAnimation.FadeOut(); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs new file mode 100644 index 000000000000..aa8f392890be --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/OpponentPickScreen.cs @@ -0,0 +1,160 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Audio; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class OpponentPickScreen : RankedPlaySubScreen + { + public CardFlow CenterRow { get; private set; } = null!; + + protected override LocalisableString StageHeading => "Pick Phase"; + protected override LocalisableString StageCaption => "Waiting for your opponent..."; + + protected override RankedPlayColourScheme ColourScheme => RankedPlayColourScheme.Red; + + private PlayerHandOfCards playerHand = null!; + private OpponentHandOfCards opponentHand = null!; + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private const int card_play_samples = 2; + private Sample?[]? cardPlaySamples; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + var matchState = Client.Room?.MatchState as RankedPlayRoomState; + + Debug.Assert(matchState != null); + + Children = + [ + CenterRow = new CardFlow + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + ]; + + CenterColumn.Children = + [ + playerHand = new PlayerHandOfCards + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Y = 100, + HoverYOffset = 90 + }, + opponentHand = new OpponentHandOfCards + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + }, + new HandReplayRecorder(playerHand), + new HandReplayPlayer(matchInfo.OpponentId, opponentHand), + ]; + + cardPlaySamples = new Sample?[card_play_samples]; + for (int i = 0; i < card_play_samples; i++) + cardPlaySamples[i] = audio.Samples.Get($@"Multiplayer/Matchmaking/Ranked/card-play-{1 + i}"); + } + + public override void OnEntering(RankedPlaySubScreen? previous) + { + base.OnEntering(previous); + + foreach (var card in matchInfo.PlayerCards) + { + playerHand.AddCard(card, c => + { + c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, DrawHeight), playerHand); + }); + } + + foreach (var card in matchInfo.OpponentCards) + { + opponentHand.AddCard(card, c => + { + c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), playerHand); + }); + } + + playerHand.UpdateLayout(stagger: 50); + opponentHand.UpdateLayout(stagger: 50); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + matchInfo.CardPlayed += cardPlayed; + } + + private void cardPlayed(RankedPlayCardWithPlaylistItem item) => Task.Run(async () => + { + if (opponentHand.Cards.FirstOrDefault(it => it.Card.Item.Equals(item)) is { } c) + await c.Card.CardRevealed.ConfigureAwait(false); + + Schedule(() => + { + RankedPlayCard? card; + + if (opponentHand.RemoveCard(item, out card, out var drawQuad)) + { + card.MatchScreenSpaceDrawQuad(drawQuad, CenterRow); + } + else + { + Logger.Log($"Played card {item.Card.ID} was not present in hand.", level: LogLevel.Error); + + card = new RankedPlayCard(item) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + CenterRow.Add(card); + + SamplePlaybackHelper.PlayWithRandomPitch(cardPlaySamples); + + card + .MoveTo(new Vector2(0), 600, Easing.OutExpo) + .ScaleTo(CENTERED_CARD_SCALE, 600, Easing.OutExpo) + .RotateTo(0, 400, Easing.OutExpo); + + opponentHand.Contract(); + playerHand.Contract(); + }); + }); + + protected override void Dispose(bool isDisposing) + { + matchInfo.CardPlayed -= cardPlayed; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs new file mode 100644 index 000000000000..2d94e28ff3de --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/PickScreen.cs @@ -0,0 +1,269 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Audio; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.RankedPlay; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class PickScreen : RankedPlaySubScreen + { + // When the 'time running out' warning sample starts to play (in remaining seconds) + private const int warning_time_threshold = 10; + + public CardFlow CenterRow { get; private set; } = null!; + + protected override LocalisableString StageHeading => "Pick Phase"; + protected override LocalisableString StageCaption => "It's your turn to play a card!"; + + private PlayerHandOfCards playerHand = null!; + private OpponentHandOfCards opponentHand = null!; + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private Sample? cardAddSample; + + private const int card_play_samples = 2; + private Sample?[]? cardPlaySamples; + + private Sample? timeRunningOutSample; + private SampleChannel? timeRunningOutSampleChannel; + private Sample? timeUpBuzzerSample; + + private DateTimeOffset stageEndTime; + private TimeSpan stageDuration; + + /// + /// Whether the local user has played a card themselves. + /// + private bool hasPlayedCard; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + var matchState = Client.Room?.MatchState as RankedPlayRoomState; + + Debug.Assert(matchState != null); + + Children = + [ + CenterRow = new CardFlow + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + ]; + + CenterColumn.Children = + [ + playerHand = new PlayerHandOfCards + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + SelectionMode = HandSelectionMode.Single, + PlayCardAction = onPlayButtonClicked + }, + opponentHand = new OpponentHandOfCards + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Y = -100, + }, + new HandReplayRecorder(playerHand), + new HandReplayPlayer(matchInfo.OpponentId, opponentHand), + ]; + + cardAddSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/card-add-1"); + + cardPlaySamples = new Sample?[card_play_samples]; + for (int i = 0; i < card_play_samples; i++) + cardPlaySamples[i] = audio.Samples.Get($@"Multiplayer/Matchmaking/Ranked/card-play-{1 + i}"); + + timeRunningOutSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/time-running-out"); + timeUpBuzzerSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Ranked/time-up"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + matchInfo.CardPlayed += cardPlayed; + + Client.CountdownStarted += onCountdownStarted; + Client.CountdownStopped += onCountdownStopped; + + if (Client.Room != null) + { + foreach (var countdown in Client.Room.ActiveCountdowns) + onCountdownStarted(countdown); + } + } + + private bool shouldPlayWarningSample + => matchInfo.Stage.Value == RankedPlayStage.CardPlay + && stageDuration > TimeSpan.FromSeconds(warning_time_threshold) + && stageEndTime - DateTimeOffset.Now < TimeSpan.FromSeconds(warning_time_threshold) + && !hasPlayedCard; + + protected override void Update() + { + base.Update(); + + if (shouldPlayWarningSample) + { + timeRunningOutSampleChannel ??= timeRunningOutSample?.GetChannel(); + + if (timeRunningOutSampleChannel == null || timeRunningOutSampleChannel.Playing) + return; + + timeRunningOutSampleChannel.ManualFree = true; + timeRunningOutSampleChannel.Looping = true; + timeRunningOutSampleChannel.Play(); + } + else + timeRunningOutSampleChannel?.Stop(); + } + + public override void OnEntering(RankedPlaySubScreen? previous) + { + base.OnEntering(previous); + + int delay = 0; + + foreach (var item in matchInfo.PlayerCards) + { + if ((previous as DiscardScreen)?.CenterRow.RemoveCard(item, out var card, out var drawQuad) == true) + { + playerHand.AddCard(card, c => + { + c.MatchScreenSpaceDrawQuad(drawQuad, playerHand); + }); + } + else + { + playerHand.AddCard(item, c => + { + c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, DrawHeight), playerHand); + }); + Scheduler.AddDelayed(() => + { + SamplePlaybackHelper.PlayWithRandomPitch(cardAddSample); + }, 50 * delay); + delay++; + } + } + + foreach (var item in matchInfo.OpponentCards) + { + opponentHand.AddCard(item, c => + { + c.Position = ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), playerHand); + }); + } + + playerHand.UpdateLayout(stagger: 50); + opponentHand.UpdateLayout(stagger: 50); + } + + private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown) + return; + + stageEndTime = DateTimeOffset.Now + countdown.TimeRemaining; + stageDuration = countdown.TimeRemaining; + }); + + private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not RankedPlayStageCountdown stageCountdown) + return; + + stageEndTime = DateTimeOffset.Now; + stageDuration = TimeSpan.Zero; + + if (stageCountdown.Stage == RankedPlayStage.CardPlay && !hasPlayedCard) + timeUpBuzzerSample?.Play(); + }); + + private void onPlayButtonClicked() + { + var selection = playerHand.Selection.SingleOrDefault(); + + if (selection != null) + { + hasPlayedCard = true; + playerHand.SelectionMode = HandSelectionMode.Disabled; + + Client.PlayCard(selection.Card).FireAndForget(); + } + + playerHand.PlayCardAction = null; + } + + private void cardPlayed(RankedPlayCardWithPlaylistItem item) + { + RankedPlayCard? card; + + if (playerHand.RemoveCard(item, out card, out var drawQuad)) + { + card.MatchScreenSpaceDrawQuad(drawQuad, CenterRow); + } + else + { + Logger.Log($"Played card {item.Card.ID} was not present in hand.", level: LogLevel.Error); + + card = new RankedPlayCard(item) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + CenterRow.Add(card); + + card + .MoveTo(new Vector2(0), 600, Easing.OutExpo) + .ScaleTo(CENTERED_CARD_SCALE, 600, Easing.OutExpo) + .RotateTo(0, 400, Easing.OutExpo); + + SamplePlaybackHelper.PlayWithRandomPitch(cardPlaySamples); + + opponentHand.Contract(); + playerHand.Contract(); + + playerHand.SelectionMode = HandSelectionMode.Disabled; + } + + protected override void Dispose(bool isDisposing) + { + timeRunningOutSampleChannel?.Stop(); + timeRunningOutSampleChannel?.Dispose(); + + matchInfo.CardPlayed -= cardPlayed; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackground.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackground.cs new file mode 100644 index 000000000000..c6316e1fb101 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackground.cs @@ -0,0 +1,213 @@ +// 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.Runtime.InteropServices; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Shaders.Types; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class RankedPlayBackground : CompositeDrawable, IBufferedDrawable + { + public float GridSize = 10; + + public IShader? TextureShader { get; private set; } + public Color4 BackgroundColour => Color4.Black; + public DrawColourInfo? FrameBufferDrawColour => null; + public Vector2 FrameBufferScale => new Vector2(0.1f); + + public Color4 GradientOutside = Color4Extensions.FromHex("AC6D97"); + public Color4 GradientInside = Color4Extensions.FromHex("544483"); + public Color4 DotsColour = Color4Extensions.FromHex("6b2980"); + + public RankedPlayBackground() + { + InternalChildren = + [ + new Triangles + { + RelativeSizeAxes = Axes.Both, + }, + ]; + } + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"RankedPlayBackground"); + } + + private readonly BufferedDrawNodeSharedData sharedData = new BufferedDrawNodeSharedData(); + + protected override void Update() + { + base.Update(); + + Invalidate(Invalidation.DrawNode); + } + + protected override DrawNode CreateDrawNode() => new RankedPlayBackgroundDrawNode(this, sharedData); + + protected override void Dispose(bool isDisposing) + { + sharedData.Dispose(); + + base.Dispose(isDisposing); + } + + private class RankedPlayBackgroundDrawNode : BufferedDrawNode, ICompositeDrawNode + { + protected new RankedPlayBackground Source => (RankedPlayBackground)base.Source; + + protected new CompositeDrawableDrawNode Child => (CompositeDrawableDrawNode)base.Child; + + public RankedPlayBackgroundDrawNode(RankedPlayBackground source, BufferedDrawNodeSharedData sharedData) + : base(source, new CompositeDrawableDrawNode(source), sharedData) + { + } + + private Vector2 drawSize; + private float time; + private float gridSize; + private Color4 gradientOutside; + private Color4 gradientInside; + private Color4 dotsColour; + + private IUniformBuffer? shaderParameterBuffer; + + public override void ApplyState() + { + base.ApplyState(); + + time = (float)(Source.Time.Current / 1000); + drawSize = Source.DrawSize; + gridSize = Source.GridSize; + gradientOutside = Source.GradientOutside; + gradientInside = Source.GradientInside; + dotsColour = Source.DotsColour; + } + + protected override void BindUniformResources(IShader shader, IRenderer renderer) + { + shaderParameterBuffer ??= renderer.CreateUniformBuffer(); + + shaderParameterBuffer.Data = new RankedPlayBackgroundParameters + { + DrawSize = drawSize, + Time = time, + GridSize = gridSize, + GradientOutside = new Vector4(gradientOutside.R, gradientOutside.G, gradientOutside.B, gradientOutside.A), + GradientInside = new Vector4(gradientInside.R, gradientInside.G, gradientInside.B, gradientInside.A), + DotsColour = new Vector4(dotsColour.R, dotsColour.G, dotsColour.B, dotsColour.A), + }; + + shader.BindUniformBlock("m_RankedPlayBackgroundParameters", shaderParameterBuffer); + } + + public List? Children + { + get => Child.Children; + set => Child.Children = value; + } + + public bool AddChildDrawNodes => RequiresRedraw; + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private record struct RankedPlayBackgroundParameters + { + public UniformVector2 DrawSize; + public UniformFloat Time; + public UniformFloat GridSize; + public UniformVector4 GradientOutside; + public UniformVector4 GradientInside; + public UniformVector4 DotsColour; + } + } + + public partial class Triangles : CompositeDrawable + { + private Texture triangleTexture = null!; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + triangleTexture = textures.Get("Online/RankedPlay/triangle"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + for (int i = 0; i < 20; i++) + { + AddInternal(new Triangle + { + Texture = triangleTexture, + RelativePositionAxes = Axes.Both, + X = RNG.NextSingle(), + Y = -0.2f + RNG.NextSingle() * 1.4f, + Origin = Anchor.Centre, + Rotation = RNG.NextSingle() * 360, + AngularVelocity = RNG.NextSingle() - 0.75f, + Size = new Vector2(100 + RNG.NextSingle() * 1000), + MovementSpeed = 0.25f + RNG.NextSingle() * 0.75f, + Alpha = 0.5f + RNG.NextSingle() * 0.5f, + }); + } + } + + public float ParticleVelocity = 1; + + protected override void Update() + { + base.Update(); + + if (DrawHeight <= 0) + return; + + float baseVelocity = 0.03f * ParticleVelocity / DrawHeight; + float elapsed = (float)Time.Elapsed; + + foreach (var c in InternalChildren) + { + var triangle = (Triangle)c; + + triangle.Y -= baseVelocity * elapsed * triangle.MovementSpeed; + + triangle.Rotation += triangle.AngularVelocity * elapsed * 0.02f; + + // wrap vertically + if (triangle.Y < -0.2f) + { + triangle.X = RNG.NextSingle(); + triangle.Y = 1.2f; + triangle.Alpha = 0.5f + RNG.NextSingle() * 0.5f; + } + else if (triangle.Y > 1.2f) + { + triangle.X = RNG.NextSingle(); + triangle.Y = -0.2f; + triangle.Alpha = 0.5f + RNG.NextSingle() * 0.5f; + } + } + } + + private partial class Triangle : Sprite + { + public float MovementSpeed = 1; + public float AngularVelocity; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackgroundScreen.cs new file mode 100644 index 000000000000..72224b876ed2 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayBackgroundScreen.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Transforms; +using osu.Game.Beatmaps; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class RankedPlayBackgroundScreen : BackgroundScreen + { + public RankedPlayBackground Background { get; } + + [Resolved] + private Bindable beatmap { get; set; } = null!; + + public Bindable ShowBeatmapBackground { get; } = new BindableBool(); + + public RankedPlayBackgroundScreen() + { + InternalChild = Background = new RankedPlayBackground + { + RelativeSizeAxes = Axes.Both, + GradientOutside = Color4Extensions.FromHex("716BE0"), + GradientInside = Color4Extensions.FromHex("#71308F"), + DotsColour = Color4Extensions.FromHex("#CC46F6").Opacity(0.5f), + }; + } + + private CancellationTokenSource? pendingBackgroundLoad; + private BeatmapBackground? currentBackground; + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(_ => updateBackground()); + ShowBeatmapBackground.BindValueChanged(_ => updateBackground()); + updateBackground(); + } + + private void updateBackground() + { + pendingBackgroundLoad?.Cancel(); + + if (beatmap.Value == null || !ShowBeatmapBackground.Value) + { + currentBackground?.PopOut().Expire(); + currentBackground = null; + return; + } + + pendingBackgroundLoad = new CancellationTokenSource(); + + LoadComponentAsync(new BeatmapBackground(beatmap.Value), background => + { + currentBackground?.PopOut().Expire(); + + AddInternal(background); + currentBackground = background; + + background.PopIn(); + }, pendingBackgroundLoad.Token); + } + + [LongRunningLoad] + private partial class BeatmapBackground(WorkingBeatmap beatmap) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChild = new BufferedContainer(cachedFrameBuffer: true) + { + RelativeSizeAxes = Axes.Both, + FrameBufferScale = new Vector2(0.15f), + GrayscaleStrength = 0.3f, + BlurSigma = new Vector2(5), + Colour = Color4Extensions.FromHex("#cccccc"), + Child = new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = beatmap.GetBackground(), + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + + public void PopIn() => this.FadeOut() + .FadeTo(0.4f, 300) + .ScaleTo(1.2f) + .ScaleTo(1f, 600, Easing.OutExpo); + + public TransformSequence PopOut() => + this.FadeOut(300).ScaleTo(1.1f, 600, Easing.OutExpo); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs new file mode 100644 index 000000000000..71ef9ee9db38 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayCardWithPlaylistItem.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public class RankedPlayCardWithPlaylistItem : IEquatable + { + public readonly Bindable PlaylistItem = new Bindable(); + public readonly RankedPlayCardItem Card; + + public RankedPlayCardWithPlaylistItem(RankedPlayCardItem card) + { + Card = card; + } + + public bool Equals(RankedPlayCardWithPlaylistItem? other) + => other != null && Card.Equals(other.Card); + + public override bool Equals(object? obj) + => obj is RankedPlayCardWithPlaylistItem other && Equals(other); + + public override int GetHashCode() + => Card.GetHashCode(); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayColourScheme.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayColourScheme.cs new file mode 100644 index 000000000000..89b95d061186 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayColourScheme.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.Color4Extensions; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public class RankedPlayColourScheme + { + public required Color4 Primary; + public required Color4 PrimaryDarker; + public required Color4 PrimaryDarkest; + public required Color4 Surface; + public required Color4 SurfaceBorder; + + public static RankedPlayColourScheme Blue => new RankedPlayColourScheme + { + Primary = Color4Extensions.FromHex("5EBFFF"), + PrimaryDarker = Color4Extensions.FromHex("4382FF"), + PrimaryDarkest = Color4Extensions.FromHex("5C55FF"), + Surface = Color4Extensions.FromHex("33303D"), + SurfaceBorder = Color4Extensions.FromHex("514c5e"), + }; + + public static RankedPlayColourScheme Red => new RankedPlayColourScheme + { + Primary = Color4Extensions.FromHex("FF8198"), + PrimaryDarker = Color4Extensions.FromHex("F94D92"), + PrimaryDarkest = Color4Extensions.FromHex("B6104D"), + Surface = Color4Extensions.FromHex("242023"), + SurfaceBorder = Color4Extensions.FromHex("403b3f"), + }; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs new file mode 100644 index 000000000000..657fbb13808d --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayMatchInfo.cs @@ -0,0 +1,176 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class RankedPlayMatchInfo : Component + { + /// + /// Cards belonging to the player. + /// + public IReadOnlyList PlayerCards => playerCards; + + /// + /// Cards belonging to the opponent. + /// + public IReadOnlyList OpponentCards => opponentCards; + + /// + /// The last card that was played. + /// + public RankedPlayCardWithPlaylistItem? LastPlayedCard { get; private set; } + + /// + /// The current room stage. + /// + public IBindable Stage => stage; + + /// + /// Fired when a card gets added to the player's hand. + /// + public event Action? PlayerCardAdded; + + /// + /// Fired when a card gets removed from the player's hand, i.e. by being discarded. + /// + public event Action? PlayerCardRemoved; + + /// + /// Fired when a card gets added to the opponent's hand. + /// + public event Action? OpponentCardAdded; + + /// + /// Fired when a card gets removed from the player's hand, i.e. by being discarded. + /// + public event Action? OpponentCardRemoved; + + /// + /// Fired when the active player plays a card. + /// + public event Action? CardPlayed; + + /// + /// The player's health + /// + public readonly BindableInt PlayerHealth = new BindableInt { MinValue = 0, MaxValue = 1_000_000, Value = 1_000_000 }; + + /// + /// The opponent's health + /// + public readonly BindableInt OpponentHealth = new BindableInt { MinValue = 0, MaxValue = 1_000_000, Value = 1_000_000 }; + + public RankedPlayRoomState RoomState { get; private set; } = null!; + + public bool IsOwnTurn => RoomState.ActiveUserId == client.LocalUser?.UserID; + + public int CurrentRound => RoomState.CurrentRound; + + public int OpponentId => RoomState.Users.Keys.Single(u => u != client.LocalUser?.UserID); + + private readonly List playerCards = new List(); + private readonly List opponentCards = new List(); + private readonly Bindable stage = new Bindable(); + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private APIUser player = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + player = client.LocalUser!.User!; + + client.MatchRoomStateChanged += onMatchRoomStateChanged; + client.RankedPlayCardAdded += onCardAdded; + client.RankedPlayCardRemoved += onCardRemoved; + client.RankedPlayCardPlayed += onCardPlayed; + + var roomState = (RankedPlayRoomState)client.Room!.MatchState!; + + onMatchRoomStateChanged(roomState); + + foreach (var (userId, user) in roomState.Users) + { + foreach (var card in user.Hand) + { + onCardAdded(userId, client.GetCardWithPlaylistItem(card)); + } + } + } + + private void onMatchRoomStateChanged(MatchRoomState state) + { + if (state is not RankedPlayRoomState roomState) + return; + + RoomState = roomState; + + stage.Value = roomState.Stage; + + foreach (var (userId, userInfo) in roomState.Users) + { + if (userId == player.Id) + PlayerHealth.Value = userInfo.Life; + else + OpponentHealth.Value = userInfo.Life; + } + } + + private void onCardAdded(int userId, RankedPlayCardWithPlaylistItem item) + { + if (userId == player.Id) + { + playerCards.Add(item); + PlayerCardAdded?.Invoke(item); + } + else + { + opponentCards.Add(item); + OpponentCardAdded?.Invoke(item); + } + } + + private void onCardRemoved(int userId, RankedPlayCardWithPlaylistItem item) + { + if (userId == player.Id) + { + playerCards.Remove(item); + PlayerCardRemoved?.Invoke(item); + } + else + { + opponentCards.Remove(item); + OpponentCardRemoved?.Invoke(item); + } + } + + private void onCardPlayed(RankedPlayCardWithPlaylistItem item) + { + LastPlayedCard = item; + CardPlayed?.Invoke(item); + } + + protected override void Dispose(bool isDisposing) + { + client.MatchRoomStateChanged -= onMatchRoomStateChanged; + client.RankedPlayCardAdded -= onCardAdded; + client.RankedPlayCardRemoved -= onCardRemoved; + client.RankedPlayCardPlayed -= onCardPlayed; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs new file mode 100644 index 000000000000..a6d152dfdf82 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs @@ -0,0 +1,536 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics.Cursor; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.Volume; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Gameplay; +using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Intro; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + [Cached] + public partial class RankedPlayScreen : OsuScreen, IPreviewTrackOwner, IHandlePresentBeatmap + { + protected override bool InitialBackButtonVisibility => false; + + public override bool HideOverlaysOnEnter => true; + + public RankedPlaySubScreen? ActiveSubScreen { get; private set; } + + protected override BackgroundScreen CreateBackground() => new RankedPlayBackgroundScreen + { + ShowBeatmapBackground = { BindTarget = showBeatmapBackground } + }; + + public override float BackgroundParallaxAmount => 0; + + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker(); + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private IDialogOverlay dialogOverlay { get; set; } = null!; + + [Resolved] + private AudioManager audio { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private MusicController music { get; set; } = null!; + + [Resolved] + private QueueController? controller { get; set; } + + private readonly MultiplayerRoom room; + private readonly Container screenContainer; + private readonly MatchmakingChatDisplay chat; + + private IBindable stage = null!; + + private Sample? sampleStart; + private CancellationTokenSource? downloadCheckCancellation; + private int? lastDownloadCheckedBeatmapId; + + private readonly Bindable cornerPieceVisibility = new Bindable(); + private readonly Bindable showBeatmapBackground = new Bindable(); + + [Cached] + private readonly RankedPlayMatchInfo matchInfo; + + [Cached] + private readonly CardDetailsOverlayContainer overlayContainer; + + [Cached] + private readonly SongPreviewParticleContainer particleContainer; + + public RankedPlayScreen(MultiplayerRoom room) + { + this.room = room; + + InternalChildren = new Drawable[] + { + matchInfo = new RankedPlayMatchInfo(), + beatmapAvailabilityTracker, + new GlobalScrollAdjustsVolume(), + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + screenContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + chat = new MatchmakingChatDisplay(new Room(room)) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Size = new Vector2(320, 160), + Margin = new MarginPadding + { + Bottom = 10, + Right = 10 + }, + Alpha = 0, + }, + new HamburgerMenu + { + Size = new Vector2(56), + } + } + } + }, + overlayContainer = new CardDetailsOverlayContainer(), + particleContainer = new SongPreviewParticleContainer(), + }; + } + + [BackgroundDependencyLoader] + private void load() + { + stage = matchInfo.Stage.GetBoundCopy(); + sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + client.UserStateChanged += onUserStateChanged; + client.SettingsChanged += onSettingsChanged; + client.LoadRequested += onLoadRequested; + + beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true); + + int localUserId = api.LocalUser.Value.OnlineID; + int opponentUserId = ((RankedPlayRoomState)client.Room!.MatchState!).Users.Keys.Single(it => it != localUserId); + + AddRangeInternal([ + new RankedPlayCornerPiece(RankedPlayColourScheme.Blue, Anchor.BottomLeft) + { + State = { BindTarget = cornerPieceVisibility }, + Child = new RankedPlayUserDisplay(localUserId, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Health = { BindTarget = matchInfo.PlayerHealth } + } + }, + new RankedPlayCornerPiece(RankedPlayColourScheme.Red, Anchor.TopRight) + { + State = { BindTarget = cornerPieceVisibility }, + Child = new RankedPlayUserDisplay(opponentUserId, Anchor.TopRight, RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Health = { BindTarget = matchInfo.OpponentHealth } + } + }, + ]); + + cornerPieceVisibility.BindValueChanged(e => + { + if (e.NewValue == Visibility.Visible) + chat.Appear(); + else + chat.Disappear(); + }); + + stage.BindValueChanged(e => onStageChanged(e.NewValue)); + } + + public void ShowScreen(RankedPlaySubScreen screen) + { + if (screen == ActiveSubScreen) + return; + + LoadComponent(screen); + + var previousScreen = ActiveSubScreen; + + screenContainer.Add(ActiveSubScreen = screen); + screen.OnLoadComplete += _ => + { + previousScreen?.OnExiting(screen); + screen.OnEntering(previousScreen); + previousScreen?.Expire(); + + if (previousScreen != null) + cornerPieceVisibility.UnbindFrom(previousScreen.CornerPieceVisibility); + + cornerPieceVisibility.BindTo(screen.CornerPieceVisibility); + showBeatmapBackground.Value = screen.ShowBeatmapBackground; + }; + } + + private void onRoomUpdated() + { + if (this.IsCurrentScreen() && client.Room == null) + { + Logger.Log($"{this} exiting due to loss of room or connection"); + exitConfirmed = true; + this.Exit(); + } + } + + private void onUserStateChanged(MultiplayerRoomUser user, MultiplayerUserState state) + { + if (user.Equals(client.LocalUser) && state == MultiplayerUserState.Idle) + this.MakeCurrent(); + } + + private void onSettingsChanged(MultiplayerRoomSettings _) => Scheduler.Add(() => + { + checkForAutomaticDownload(); + updateGameplayState(); + }); + + private void onLoadRequested() => Scheduler.Add(() => + { + updateGameplayState(); + + if (Beatmap.IsDefault) + { + Logger.Log("Aborting gameplay start - beatmap not downloaded."); + return; + } + + sampleStart?.Play(); + + this.Push(new MultiplayerPlayerLoader(() => new ScreenGameplay(new Room(room), new PlaylistItem(client.Room!.CurrentPlaylistItem), room.Users.ToArray()))); + }); + + private void onStageChanged(RankedPlayStage stage) + { + switch (stage) + { + case RankedPlayStage.RoundWarmup when matchInfo.CurrentRound == 1: + ShowScreen(new IntroScreen()); + break; + + case RankedPlayStage.CardDiscard: + ShowScreen(new DiscardScreen()); + break; + + case RankedPlayStage.FinishCardDiscard: + (ActiveSubScreen as DiscardScreen)?.PresentRemainingCards(); + break; + + case RankedPlayStage.CardPlay: + ShowScreen(matchInfo.IsOwnTurn ? new PickScreen() : new OpponentPickScreen()); + break; + + case RankedPlayStage.FinishCardPlay: + Debug.Assert(ActiveSubScreen is PickScreen || ActiveSubScreen is OpponentPickScreen); + break; + + case RankedPlayStage.GameplayWarmup: + ShowScreen(new GameplayWarmupScreen()); + break; + + case RankedPlayStage.Gameplay: + ShowScreen(new GameplayScreen()); + break; + + case RankedPlayStage.Results: + ShowScreen(new ResultsScreen()); + break; + + case RankedPlayStage.Ended: + ShowScreen(new EndedScreen + { + ExitRequested = retry => + { + retryRequested = retry; + exitConfirmed = true; + + if (this.IsCurrentScreen()) + this.Exit(); + } + }); + break; + } + } + + private void onBeatmapAvailabilityChanged(ValueChangedEvent e) => Scheduler.Add(() => + { + if (client.Room == null || client.LocalUser == null) + return; + + client.ChangeBeatmapAvailability(e.NewValue).FireAndForget(); + + switch (e.NewValue.State) + { + case DownloadState.NotDownloaded: + case DownloadState.LocallyAvailable: + updateGameplayState(); + break; + } + }); + + private void updateGameplayState() + { + MultiplayerPlaylistItem item = client.Room!.CurrentPlaylistItem; + + if (item.Expired) + return; + + RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!; + Ruleset rulesetInstance = ruleset.CreateInstance(); + + // Update global gameplay state to correspond to the new selection. + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID); + + if (localBeatmap != null) + { + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + Ruleset.Value = ruleset; + Mods.Value = item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + + // Notify the server that the beatmap has been set and that we are ready to start gameplay. + if (client.LocalUser!.State == MultiplayerUserState.Idle) + client.ChangeState(MultiplayerUserState.Ready).FireAndForget(); + } + else + { + // Notify the server that we don't have the beatmap. + if (client.LocalUser!.State == MultiplayerUserState.Ready) + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); + } + + client.ChangeBeatmapAvailability(beatmapAvailabilityTracker.Availability.Value).FireAndForget(); + } + + private void checkForAutomaticDownload() + { + if (client.Room == null) + return; + + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + + // This method is called every time anything changes in the room. + // This could result in download requests firing far too often, when we only expect them to fire once per beatmap. + // + // Without this check, we would see especially egregious behaviour when a user has hit the download rate limit. + if (lastDownloadCheckedBeatmapId == item.BeatmapID) + return; + + lastDownloadCheckedBeatmapId = item.BeatmapID; + + downloadCheckCancellation?.Cancel(); + + if (beatmapManager.IsAvailableLocally(new APIBeatmap { OnlineID = item.BeatmapID })) + return; + + // In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes. + // ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised. + beatmapLookupCache + .GetBeatmapAsync(item.BeatmapID, (downloadCheckCancellation = new CancellationTokenSource()).Token) + .ContinueWith(resolved => Schedule(() => + { + APIBeatmapSet? beatmapSet = resolved.GetResultSafely()?.BeatmapSet; + + if (beatmapSet == null) + return; + + beatmapDownloader.Download(beatmapSet, config.Get(OsuSetting.PreferNoVideo)); + })); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + beginHandlingTrack(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + endHandlingTrack(); + + base.OnSuspending(e); + } + + private bool exitConfirmed; + private bool retryRequested; + + public override bool OnExiting(ScreenExitEvent e) + { + if (exitConfirmed || ActiveSubScreen is EndedScreen) + { + if (base.OnExiting(e)) + { + exitConfirmed = false; + return true; + } + + endHandlingTrack(); + + client.LeaveRoom().FireAndForget(); + + if (retryRequested) + controller?.RejoinQueue(); + + return false; + } + + if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) + confirmDialog.PerformOkAction(); + else + { + dialogOverlay.Push(new ConfirmExitMultiplayerMatchDialog(() => + { + exitConfirmed = true; + if (this.IsCurrentScreen()) + this.Exit(); + })); + } + + return true; + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + + beginHandlingTrack(); + + if (e.Last is not MultiplayerPlayerLoader playerLoader) + return; + + if (!playerLoader.GameplayPassed) + { + client.AbortGameplay().FireAndForget(); + return; + } + + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); + } + + /// + /// Handles changes in the track to keep it looping while active. + /// + private void beginHandlingTrack() + { + Beatmap.BindValueChanged(applyLoopingToTrack, true); + } + + /// + /// Stops looping the current track and stops handling further changes to the track. + /// + private void endHandlingTrack() + { + Beatmap.ValueChanged -= applyLoopingToTrack; + Beatmap.Value.Track.Looping = false; + + previewTrackManager.StopAnyPlaying(this); + } + + /// + /// Invoked on changes to the beatmap to loop the track. See: . + /// + /// The beatmap change event. + private void applyLoopingToTrack(ValueChangedEvent beatmap) + { + if (!this.IsCurrentScreen()) + return; + + beatmap.NewValue.PrepareTrackForPreview(true); + music.EnsurePlayingSomething(); + } + + public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + // Do nothing to prevent the user from potentially being kicked out + // of gameplay due to the screen performer's internal processes. + } + + protected override void Dispose(bool isDisposing) + { + client.RoomUpdated -= onRoomUpdated; + client.UserStateChanged -= onUserStateChanged; + client.SettingsChanged -= onSettingsChanged; + client.LoadRequested -= onLoadRequested; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlaySubScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlaySubScreen.cs new file mode 100644 index 000000000000..46aa1d20cb0e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlaySubScreen.cs @@ -0,0 +1,112 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Globalization; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public abstract partial class RankedPlaySubScreen : Container + { + public const float CENTERED_CARD_SCALE = 1.2f; + + public readonly Bindable CornerPieceVisibility = new Bindable(Visibility.Visible); + protected readonly Bindable CountdownVisibility = new Bindable(Visibility.Visible); + + public virtual bool ShowBeatmapBackground => false; + + /// + /// Heading text to be displayed indicating the purpose of the current stage. + /// + protected abstract LocalisableString StageHeading { get; } + + /// + /// Subtitle text to be displayed indicating the action a user should take in the current stage. + /// + protected abstract LocalisableString StageCaption { get; } + + /// + /// The colour scheme commonly used for components of this screen. + /// + protected virtual RankedPlayColourScheme ColourScheme => RankedPlayColourScheme.Blue; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + protected MultiplayerClient Client => client; + + protected override Container Content { get; } + protected readonly Container CenterColumn; + protected readonly FillFlowContainer ButtonsContainer; + protected readonly RankedPlayStageDisplay StageDisplay; + + protected RankedPlaySubScreen() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = + [ + CenterColumn = new Container + { + Name = "Center Column", + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(20), + }, + Content = new Container + { + Name = "Content", + RelativeSizeAxes = Axes.Both, + }, + ButtonsContainer = new FillFlowContainer + { + Name = "Buttons", + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + X = 30, + Y = -110, + Direction = FillDirection.Vertical, + Spacing = new Vector2(8) + }, + StageDisplay = new RankedPlayStageDisplay(ColourScheme) + { + Heading = StageHeading, + Caption = StageCaption, + Margin = new MarginPadding { Top = 60 }, + State = { BindTarget = CountdownVisibility } + }, + ]; + } + + protected override void Update() + { + base.Update(); + + CenterColumn.Width = DrawWidth - RankedPlayCornerPiece.WidthFor(DrawWidth) * 2; + } + + public virtual void OnEntering(RankedPlaySubScreen? previous) + { + } + + public virtual void OnExiting(RankedPlaySubScreen? next) + { + Hide(); + } + + protected static string FormatRoundIndex(int roundNumber) + { + return roundNumber >= 10 ? roundNumber.Ordinalize(CultureInfo.InvariantCulture) : roundNumber.ToOrdinalWords(CultureInfo.InvariantCulture); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.PanelScaffold.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.PanelScaffold.cs new file mode 100644 index 000000000000..e4c69f9ee70c --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.PanelScaffold.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + public partial class PanelScaffold : Container + { + private const float corner_radius = 6; + private const float border_thickness = 2; + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + public readonly ScreenBottomOrnament BottomOrnament = new ScreenBottomOrnament(); + + private BufferedContainer background = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = -30 }, + Child = background = new BufferedContainer(cachedFrameBuffer: false) + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 30 }, + BackgroundColour = Color4Extensions.FromHex("222228").Opacity(0), + Alpha = 0.7f, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = corner_radius, + BorderThickness = border_thickness, + BorderColour = new ColourInfo + { + TopLeft = RankedPlayColourScheme.Blue.PrimaryDarkest.Opacity(0.5f), + BottomLeft = RankedPlayColourScheme.Blue.Primary.Opacity(0.75f), + TopRight = RankedPlayColourScheme.Red.PrimaryDarkest.Opacity(0.5f), + BottomRight = RankedPlayColourScheme.Red.Primary.Opacity(0.75f), + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("222228"), + }, + }, + } + }, + Content.With(static d => + { + d.Masking = true; + d.CornerRadius = corner_radius; + }), + BottomOrnament.With(static d => + { + d.Anchor = Anchor.BottomCentre; + d.Origin = Anchor.Centre; + d.Y -= border_thickness / 2; + }), + ]; + + background.Add(BottomOrnament.Background.CreateProxy()); + } + } + + public partial class ScreenBottomOrnament : Container + { + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both, }; + + public Drawable Background => background; + + private readonly Container background = new Container { RelativeSizeAxes = Axes.Both }; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + InternalChildren = + [ + background.WithChildren([ + new NineSliceSprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("Online/RankedPlay/damage-display-background"), + TextureInsetRelativeAxes = Axes.None, + TextureInset = new MarginPadding { Horizontal = 30 }, + Colour = Color4Extensions.FromHex("222228"), + }, + new NineSliceSprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("Online/RankedPlay/damage-display-border"), + TextureInsetRelativeAxes = Axes.None, + TextureInset = new MarginPadding { Horizontal = 30 }, + Alpha = 0.25f, + Colour = Color4Extensions.FromHex("ddddff") + }, + ]), + Content, + ]; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreBar.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreBar.cs new file mode 100644 index 000000000000..7aee36358faf --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreBar.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + public partial class ScoreBar(RankedPlayColourScheme colours) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = 6; + BorderThickness = 2; + BorderColour = colours.PrimaryDarkest.Darken(0.35f); + + InternalChildren = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Height = 1 / 3f, + Colour = ColourInfo.GradientVertical(colours.Primary, colours.PrimaryDarker) + }, + new Box + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Y, + Height = 2f / 3f, + Y = 1f / 3f, + Colour = ColourInfo.GradientVertical(colours.PrimaryDarker, colours.PrimaryDarkest) + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(3), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 3, + Colour = ColourInfo.GradientHorizontal(Colour4.White, Colour4.White.Opacity(0)), + BorderThickness = 3, + BorderColour = ColourInfo.GradientVertical(Colour4.White, Colour4.White.Opacity(0)), + Alpha = 0.25f, + Blending = BlendingParameters.Additive, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + } + }, + ]; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreDetails.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreDetails.cs new file mode 100644 index 000000000000..f8a55e8778d9 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreDetails.cs @@ -0,0 +1,109 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + public partial class ScoreDetails(ScoreInfo score, RankedPlayColourScheme colours) : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(30), + Children = + [ + new ScoreStatisticsDisplay(score, colours) + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + ColumnDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + ], + RowDimensions = [new Dimension(GridSizeMode.AutoSize)], + Content = new Drawable[][] + { + [ + new ScoreRankDisplay(score) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(20), + Children = + [ + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = + [ + new OsuSpriteText + { + Text = "Accuracy", + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Text = score.DisplayAccuracy, + Font = OsuFont.GetFont(size: 36, weight: FontWeight.SemiBold) + }, + ] + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = + [ + new OsuSpriteText + { + Text = "Combo", + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Text = $"{score.MaxCombo}x", + Font = OsuFont.GetFont(size: 36, weight: FontWeight.SemiBold) + }, + ] + } + ] + } + ] + } + } + ] + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreRankDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreRankDisplay.cs new file mode 100644 index 000000000000..b3981f05812f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreRankDisplay.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Online.Leaderboards; +using osu.Game.Scoring; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + private partial class ScoreRankDisplay : CompositeDrawable + { + private readonly ScoreInfo score; + + public ScoreRankDisplay(ScoreInfo score) + { + this.score = score; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(SkinManager skinManager) + { + InternalChild = new Sprite + { + Scale = new Vector2(0.5f), + Texture = skinManager.DefaultClassicSkin.GetTexture(DrawableRank.GetLegacyRankTextureName(score.Rank)) + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreStatisticsDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreStatisticsDisplay.cs new file mode 100644 index 000000000000..0f1540220011 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.ScoreStatisticsDisplay.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Scoring; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen + { + private partial class ScoreStatisticsDisplay : CompositeDrawable + { + private readonly ScoreInfo score; + private readonly RankedPlayColourScheme colours; + + private FillFlowContainer statisticsFlow = null!; + + public ScoreStatisticsDisplay(ScoreInfo score, RankedPlayColourScheme colours) + { + this.score = score; + this.colours = colours; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + statisticsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10, 20), + Children = score.GetStatisticsForDisplay().Select(it => new BeatmapTitleWedge.StatisticDifficulty + { + Width = 80, + Value = new BeatmapTitleWedge.StatisticDifficulty.Data(it.DisplayName.ToTitle(), it.Count, it.Count, it.MaxCount ?? it.Count), + AccentColour = colours.PrimaryDarker, + }).ToArray(), + } + } + }; + } + + protected override void Update() + { + base.Update(); + + int statisticsPerRow = (statisticsFlow.Count + 1) / 2; + float statisticWidth = (DrawWidth - (statisticsPerRow - 1) * statisticsFlow.Spacing.X) / statisticsPerRow; + foreach (var statistic in statisticsFlow) + statistic.Width = statisticWidth; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs new file mode 100644 index 000000000000..129ffc61dc7d --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs @@ -0,0 +1,608 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay +{ + public partial class ResultsScreen : RankedPlaySubScreen + { + protected override LocalisableString StageHeading => "Results"; + protected override LocalisableString StageCaption => string.Empty; + + public override bool ShowBeatmapBackground => true; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private IBindable globalRuleset { get; set; } = null!; + + private LoadingSpinner loadingSpinner = null!; + + [BackgroundDependencyLoader] + private void load() + { + CornerPieceVisibility.Value = Visibility.Hidden; + + AddInternal(loadingSpinner = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + loadingSpinner.Show(); + + queryScores().FireAndForget(); + } + + private async Task queryScores() + { + try + { + if (client.Room == null) + return; + + Task beatmapTask = beatmapLookupCache.GetBeatmapAsync(client.Room.CurrentPlaylistItem.BeatmapID); + TaskCompletionSource> scoreTask = new TaskCompletionSource>(); + + var request = new IndexPlaylistScoresRequest(client.Room.RoomID, client.Room.Settings.PlaylistItemId); + request.Success += req => scoreTask.SetResult(req.Scores); + request.Failure += scoreTask.SetException; + api.Queue(request); + + await Task.WhenAll(beatmapTask, scoreTask.Task).ConfigureAwait(false); + + APIBeatmap? apiBeatmap = beatmapTask.GetResultSafely(); + List apiScores = scoreTask.Task.GetResultSafely(); + + if (apiBeatmap == null) + return; + + // Reference: PlaylistItemResultsScreen + setScores(apiScores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, new BeatmapInfo + { + Difficulty = new BeatmapDifficulty(apiBeatmap.Difficulty), + Metadata = + { + Artist = apiBeatmap.Metadata.Artist, + Title = apiBeatmap.Metadata.Title, + Author = new RealmUser + { + Username = apiBeatmap.Metadata.Author.Username, + OnlineID = apiBeatmap.Metadata.Author.OnlineID, + } + }, + DifficultyName = apiBeatmap.DifficultyName, + StarRating = apiBeatmap.StarRating, + Length = apiBeatmap.Length, + BPM = apiBeatmap.BPM + })).ToArray()); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load scores for playlist item."); + throw; + } + finally + { + Scheduler.Add(() => loadingSpinner.Hide()); + } + } + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + private void setScores(ScoreInfo[] scores) => Scheduler.Add(() => + { + int playerId = api.LocalUser.Value.OnlineID; + int opponentId = matchInfo.RoomState.Users.Keys.Single(it => it != playerId); + + ScoreInfo playerScore = scores.SingleOrDefault(s => s.UserID == playerId) ?? new ScoreInfo + { + Rank = ScoreRank.F, + Ruleset = globalRuleset.Value, + User = new APIUser { Id = playerId } + }; + + ScoreInfo opponentScore = scores.SingleOrDefault(s => s.UserID == opponentId) ?? new ScoreInfo + { + Rank = ScoreRank.F, + Ruleset = globalRuleset.Value, + User = new APIUser { Id = opponentId } + }; + + AddInternal(new ResultScreenContent + { + RelativeSizeAxes = Axes.Both, + // A little bit of room for the countdown timer... + Margin = new MarginPadding { Top = 45 }, + PlayerScore = playerScore, + OpponentScore = opponentScore, + PlayerDamageInfo = matchInfo.RoomState.Users[playerId].DamageInfo!, + OpponentDamageInfo = matchInfo.RoomState.Users[opponentId].DamageInfo!, + }); + }); + + private partial class ResultScreenContent : CompositeDrawable + { + public required ScoreInfo PlayerScore { get; init; } + public required ScoreInfo OpponentScore { get; init; } + public required RankedPlayDamageInfo PlayerDamageInfo { get; init; } + public required RankedPlayDamageInfo OpponentDamageInfo { get; init; } + + [Resolved] + private RankedPlayMatchInfo matchInfo { get; set; } = null!; + + [Resolved] + private OsuColour colour { get; set; } = null!; + + private static Vector2 cardSize => new Vector2(950, 550); + + private readonly Bindable cornerPieceVisibility = new Bindable(); + private readonly Bindable scoreBarProgress = new Bindable(); + + private PanelScaffold panelScaffold = null!; + private Box flash = null!; + private ScoreDetails playerScoreDetails = null!; + private ScoreDetails opponentScoreDetails = null!; + private RankedPlayScoreCounter playerScoreCounter = null!; + private RankedPlayScoreCounter opponentScoreCounter = null!; + private RankedPlayScoreCounter damageCounter = null!; + private OsuSpriteText flyingDamageText = null!; + private ScoreBar playerScoreBar = null!; + private ScoreBar opponentScoreBar = null!; + private OsuSpriteText roundNumber = null!; + private RankedPlayUserDisplay playerUserDisplay = null!; + private RankedPlayUserDisplay opponentUserDisplay = null!; + + private RankedPlayDamageInfo losingDamageInfo = null!; + + [BackgroundDependencyLoader] + private void load() + { + // this works under the assumption that only one player can receive damage each round + losingDamageInfo = matchInfo.RoomState.Users + .Select(it => it.Value.DamageInfo) + .OfType() + .MaxBy(it => it.Damage)!; + + AddInternal(panelScaffold = new PanelScaffold + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + new RankedPlayCornerPiece(RankedPlayColourScheme.Blue, Anchor.BottomLeft) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + State = { BindTarget = cornerPieceVisibility }, + Child = playerUserDisplay = new RankedPlayUserDisplay(PlayerScore.UserID, Anchor.BottomLeft, RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Health = { Value = PlayerDamageInfo.OldLife } + } + }, + new RankedPlayCornerPiece(RankedPlayColourScheme.Red, Anchor.BottomRight) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + State = { BindTarget = cornerPieceVisibility }, + Child = opponentUserDisplay = new RankedPlayUserDisplay(OpponentScore.UserID, Anchor.BottomRight, RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Health = { Value = OpponentDamageInfo.OldLife } + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 110, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Padding = new MarginPadding { Bottom = 30 }, + Child = roundNumber = new OsuSpriteText + { + Text = $"Round {matchInfo.CurrentRound}", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 36, weight: FontWeight.Bold, typeface: Typeface.TorusAlternate), + Alpha = 0, + }, + }, + new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = cardSize, + Padding = new MarginPadding { Bottom = 110, Top = 60, Horizontal = 60 }, + ColumnDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.Absolute, 40), + new Dimension(GridSizeMode.Absolute, 60), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(GridSizeMode.Absolute, 60), + new Dimension(GridSizeMode.Absolute, 40), + new Dimension(), + ], + Content = new Drawable?[][] + { + [ + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + ], + Content = new Drawable[][] + { + [ + playerScoreDetails = new ScoreDetails(PlayerScore, RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + ], + [ + playerScoreCounter = new RankedPlayScoreCounter(numDigits(PlayerScore.TotalScore)) + { + Font = OsuFont.GetFont(size: 60, fixedWidth: true), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(-4), + Alpha = 0, + AlwaysPresent = true, + } + ] + } + }, + null, + playerScoreBar = new ScoreBar(RankedPlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Height = 0.05f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Alpha = 0, + }, + null, + opponentScoreBar = new ScoreBar(RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Height = 0.05f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Alpha = 0, + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + ], + Content = new Drawable[][] + { + [ + opponentScoreDetails = new ScoreDetails(OpponentScore, RankedPlayColourScheme.Red) + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + ], + [ + opponentScoreCounter = new RankedPlayScoreCounter(numDigits(OpponentScore.TotalScore)) + { + Font = OsuFont.GetFont(size: 60, fixedWidth: true), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(-4), + Alpha = 0, + AlwaysPresent = true, + } + ] + } + }, + ] + } + }, + flash = new Box + { + RelativeSizeAxes = Axes.Both, + }, + ], + BottomOrnament = + { + Size = new Vector2(200, 60), + Alpha = 0, + Children = + [ + new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = + [ + damageCounter = new RankedPlayScoreCounter(numDigits(losingDamageInfo.Damage)) + { + Font = OsuFont.GetFont(size: 36, weight: FontWeight.SemiBold, fixedWidth: true), + Spacing = new Vector2(-2), + }, + flyingDamageText = new OsuSpriteText + { + Text = FormattableString.Invariant($"{losingDamageInfo.Damage:N0}"), + Font = OsuFont.GetFont(size: 36, weight: FontWeight.SemiBold, fixedWidth: true), + Spacing = new Vector2(-2), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BypassAutoSizeAxes = Axes.Both, + Alpha = 0, + }, + new OsuSpriteText + { + BypassAutoSizeAxes = Axes.Both, + Text = $"{matchInfo.RoomState.DamageMultiplier.ToStandardFormattedString(maxDecimalDigits: 1)}x", + Anchor = Anchor.CentreRight, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 42), + Rotation = 30, + Alpha = 0, + Colour = colour.RedLight + }, + ] + }, + new OsuSpriteText + { + Text = Precision.AlmostEquals(matchInfo.RoomState.DamageMultiplier, 1) + ? "Damage" + : $"Damage {matchInfo.RoomState.DamageMultiplier.ToStandardFormattedString(maxDecimalDigits: 1)}x", + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 22), + }, + ] + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + double delay = 0; + + appear(ref delay); + + animateCountersAndScoreBars(ref delay); + + showScoreInfo(ref delay); + + updateHealthBars(ref delay); + } + + private void appear(ref double delay) + { + panelScaffold.FadeIn(100) + .ResizeTo(0) + .ResizeTo(cardSize with { Y = 30 }, 600, Easing.OutExpo) + // deliberately cutting this delay 300ms short so the vertical resize interrupts the horizontal one + .Delay(300) + .ResizeHeightTo(cardSize.Y, 800, Easing.OutExpo); + + flash.Delay(150).FadeOut(600, Easing.Out); + + using (BeginDelayedSequence(700)) + { + roundNumber.FadeIn(600); + playerScoreCounter.FadeIn(600); + opponentScoreCounter.FadeIn(600); + + Schedule(() => cornerPieceVisibility.Value = Visibility.Visible); + } + + using (BeginDelayedSequence(900)) + { + panelScaffold.BottomOrnament + .FadeIn(300) + .ResizeWidthTo(cardSize.X - 550, 600, Easing.OutExpo); + } + + delay += 1000; + } + + private void animateCountersAndScoreBars(ref double delay) + { + using (BeginDelayedSequence(delay)) + { + const double score_text_duration = 2000; + + playerScoreCounter.TransformValueTo(PlayerScore.TotalScore, score_text_duration - 500); + opponentScoreCounter.TransformValueTo(OpponentScore.TotalScore, score_text_duration - 500); + + damageCounter.TransformValueTo(losingDamageInfo.Damage, score_text_duration - 500); + + long maxAchievableScore = Math.Max( + Math.Max(PlayerScore.TotalScore, OpponentScore.TotalScore), + 1_000_000 + ); + + float playerScorePercent = (float)PlayerScore.TotalScore / maxAchievableScore; + float opponentScorePercent = (float)OpponentScore.TotalScore / maxAchievableScore; + float maxScorePercent = Math.Max(playerScorePercent, opponentScorePercent); + + playerScoreBar.FadeIn(100); + opponentScoreBar.FadeIn(100); + + this.TransformBindableTo(scoreBarProgress, maxScorePercent, score_text_duration, new CubicBezierEasingFunction(easeIn: 0.4, easeOut: 1)); + + scoreBarProgress.BindValueChanged(e => + { + playerScoreBar.Height = float.Lerp(0.05f, 1f, Math.Min(e.NewValue, playerScorePercent)); + opponentScoreBar.Height = float.Lerp(0.05f, 1f, Math.Min(e.NewValue, opponentScorePercent)); + }); + } + + delay += 2200; + } + + private void updateHealthBars(ref double delay) + { + const double text_movement_duration = 400; + + using (BeginDelayedSequence(delay)) + { + Schedule(() => + { + RankedPlayUserDisplay userDisplay = + PlayerScore.TotalScore > OpponentScore.TotalScore + ? opponentUserDisplay + : playerUserDisplay; + + Vector2 screenSpacePosition = userDisplay.HealthDisplay.ScreenSpaceImpactPosition; + + var position = flyingDamageText.Parent!.ToLocalSpace(screenSpacePosition) - flyingDamageText.AnchorPosition; + + damageCounter.FadeOut() + .Delay(200) + .FadeIn(300) + .ScaleTo(0.9f) + .ScaleTo(1f, 300, Easing.OutElasticHalf); + + flyingDamageText.FadeIn() + .MoveTo(position, text_movement_duration, Easing.InCubic) + .ScaleTo(0.75f, text_movement_duration, new CubicBezierEasingFunction(easeIn: 0.35, easeOut: 0.5)) + .RotateTo(12 * Math.Sign(position.X), text_movement_duration, new CubicBezierEasingFunction(easeIn: 0.35, easeOut: 0.5)) + .Then() + .FadeOut(); + + Scheduler.AddDelayed(() => + { + userDisplay.Shake(shakeDuration: 60, shakeMagnitude: 2, maximumLength: 120); + + for (int i = 0; i < 10; i++) + { + var particle = new DamageParticle + { + Size = new Vector2(RNG.NextSingle(5, 15)), + Origin = Anchor.Centre, + Position = ToLocalSpace(screenSpacePosition), + Rotation = RNG.NextSingle(0, 360), + Blending = BlendingParameters.Additive, + }; + + AddInternal(particle); + + particle.FadeOut(600) + .ScaleTo(0, 600) + .RotateTo(particle.Rotation + RNG.NextSingle(-20, 20), 600) + .FadeColour(Color4.Red, 600) + .Expire(); + } + }, text_movement_duration); + }); + } + + delay += text_movement_duration; + + using (BeginDelayedSequence(delay)) + { + Schedule(() => + { + playerUserDisplay.Health.Value = PlayerDamageInfo.NewLife; + opponentUserDisplay.Health.Value = OpponentDamageInfo.NewLife; + }); + } + + delay += 400; + } + + private void showScoreInfo(ref double delay) + { + using (BeginDelayedSequence(delay)) + { + playerScoreDetails.FadeIn(300); + opponentScoreDetails.FadeIn(300); + } + + delay += 800; + } + + private static int numDigits(long value) + { + if (value <= 0) + return 1; + + return (int)Math.Floor(Math.Log10(value)) + 1; + } + + private partial class DamageParticle : Triangle + { + private Vector2 velocity = new Vector2(RNG.NextSingle(-0.3f, 0.3f), RNG.NextSingle(-0.3f, 0.3f)); + + private Vector2 gravity => new Vector2(0, 0.0002f); + + protected override void Update() + { + base.Update(); + + velocity += gravity * (float)Time.Elapsed; + Position += velocity * (float)Time.Elapsed; + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index a91b84490006..97f30035cf88 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Threading; +using osu.Game.Localisation; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Overlays; @@ -50,11 +51,12 @@ public MatchStartControl() ColumnDimensions = new[] { new Dimension(), + new Dimension(GridSizeMode.Absolute, 5), new Dimension(GridSizeMode.AutoSize) }, Content = new[] { - new Drawable[] + new Drawable?[] { readyButton = new MultiplayerReadyButton { @@ -62,6 +64,7 @@ public MatchStartControl() Size = Vector2.One, Action = onReadyButtonClick, }, + null, countdownButton = new MultiplayerCountdownButton { RelativeSizeAxes = Axes.Y, @@ -257,7 +260,7 @@ public partial class ConfirmAbortDialog : DangerousActionDialog { public ConfirmAbortDialog(Action abortMatch, Action cancel) { - HeaderText = "Are you sure you want to abort the match?"; + HeaderText = DialogStrings.ConfirmAbortMatchHeaderText; DangerousAction = abortMatch; CancelAction = cancel; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 018d36069e0a..80179bd1dd29 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -370,7 +370,7 @@ private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) break; case nameof(Room.Type): - updateRoomName(); + updateRoomType(); break; case nameof(Room.QueueMode): diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index eb387b2664fa..b58041aa6fbc 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -90,6 +90,15 @@ private void transitionFromResults() client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); } + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + client.LeaveRoom().FireAndForget(); + return false; + } + protected override string ScreenTitle => "Multiplayer"; protected override LoungeSubScreen CreateLounge() => new MultiplayerLoungeSubScreen(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs index 846f781cdc11..8b0fc786900e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs @@ -25,15 +25,15 @@ public partial class MultiplayerMatchFreestyleSelect : OnlinePlayFreestyleSelect private LoadingLayer loadingLayer = null!; private IDisposable? selectionOperation; - public MultiplayerMatchFreestyleSelect(Room room, PlaylistItem item) - : base(room, item) + public MultiplayerMatchFreestyleSelect(PlaylistItem item) + : base(item) { } [BackgroundDependencyLoader] private void load() { - AddInternal(loadingLayer = new LoadingLayer(true)); + AddInternal(loadingLayer = new LoadingLayer(dimBackground: true) { BlockNonPositionalInput = true }); } protected override void LoadComplete() @@ -52,17 +52,14 @@ private void updateLoadingLayer() loadingLayer.Hide(); } - protected override bool OnStart() + protected override void StartAction() { if (operationInProgress.Value) { Logger.Log($"{nameof(OnStart)} aborted due to {nameof(operationInProgress)}"); - return false; + return; } - if (!base.OnStart()) - return false; - selectionOperation = operationTracker.BeginOperation(); client.ChangeUserStyle(Beatmap.Value.BeatmapInfo.OnlineID, Ruleset.Value.OnlineID) @@ -79,14 +76,7 @@ protected override bool OnStart() }, onError: _ => { selectionOperation.Dispose(); - - Schedule(() => - { - Carousel.AllowSelection = true; - }); }); - - return true; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index 7328e01026b4..706f86a6eb4f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -2,73 +2,230 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.Footer; using osu.Game.Screens.Select; +using osu.Game.Users; +using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public partial class MultiplayerMatchSongSelect : OnlinePlaySongSelect + public partial class MultiplayerMatchSongSelect : SongSelect, IOnlinePlaySubScreen, ISongSelect { + public string ShortTitle => "song selection"; + + public override string Title => ShortTitle.Humanize(); + [Resolved] private MultiplayerClient client { get; set; } = null!; [Resolved] private OngoingOperationTracker operationTracker { get; set; } = null!; + [Resolved] + private IOverlayManager? overlayManager { get; set; } + private readonly Room room; private readonly IBindable operationInProgress = new Bindable(); private readonly PlaylistItem? itemToEdit; + private ModSelectOverlay modSelect = null!; private LoadingLayer loadingLayer = null!; private IDisposable? selectionOperation; + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + protected override UserActivity InitialActivity => new UserActivity.InLobby(room); + + protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); + + private readonly Bindable freestyle = new Bindable(true); + + private readonly PlaylistItem? initialItem; + private readonly FreeModSelectOverlay freeModSelect; + + private IDisposable? freeModSelectOverlayRegistration; + /// /// Construct a new instance of multiplayer song select. /// /// The room. /// The item to be edited. May be null, in which case a new item will be added to the playlist. public MultiplayerMatchSongSelect(Room room, PlaylistItem? itemToEdit = null) - : base(room, itemToEdit) { this.room = room; this.itemToEdit = itemToEdit; + initialItem = itemToEdit ?? room.Playlist.LastOrDefault(); + + Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; + LeftPadding = new MarginPadding { Top = CORNER_RADIUS_HIDE_OFFSET + Header.HEIGHT }; + + freeModSelect = new FreeModSelectOverlay + { + SelectedMods = { BindTarget = FreeMods }, + IsValidMod = isValidAllowedMod, + }; } [BackgroundDependencyLoader] private void load() { - AddInternal(loadingLayer = new LoadingLayer(true)); + LoadComponent(freeModSelect); + AddInternal(loadingLayer = new LoadingLayer(true) + { + BlockNonPositionalInput = true, + }); } protected override void LoadComplete() { base.LoadComplete(); + if (initialItem != null) + { + // Prefer using a local databased beatmap lookup since OnlineId may be -1 for an invalid beatmap selection. + BeatmapInfo? beatmapInfo = initialItem.Beatmap as BeatmapInfo; + + // And in the case that this isn't a local databased beatmap, query by online ID. + if (beatmapInfo == null) + { + int onlineId = initialItem.Beatmap.OnlineID; + beatmapInfo = beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId); + } + + if (beatmapInfo != null) + Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo); + + RulesetInfo? ruleset = rulesets.GetRuleset(initialItem.RulesetID); + + if (ruleset != null) + { + Ruleset.Value = ruleset; + + var rulesetInstance = ruleset.CreateInstance(); + Debug.Assert(rulesetInstance != null); + + // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. + // Similarly, freeMods is currently empty but should only contain the allowed mods. + Mods.Value = initialItem.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + FreeMods.Value = initialItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + } + + freestyle.Value = initialItem.Freestyle; + } + + Mods.BindValueChanged(_ => updateValidMods()); + Ruleset.BindValueChanged(onRulesetChanged); + freestyle.BindValueChanged(onFreestyleChanged); + + freeModSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(freeModSelect); + + updateFooterButtons(); + updateValidMods(); + operationInProgress.BindTo(operationTracker.InProgress); - operationInProgress.BindValueChanged(_ => updateLoadingLayer(), true); + operationInProgress.BindValueChanged(operation => + { + if (operation.NewValue) + loadingLayer.Show(); + else + loadingLayer.Hide(); + }, true); } - private void updateLoadingLayer() + private void onFreestyleChanged(ValueChangedEvent enabled) { - if (operationInProgress.Value) - loadingLayer.Show(); + updateFooterButtons(); + updateValidMods(); + + if (enabled.NewValue) + { + // Freestyle allows all mods to be selected as freemods. This does not play nicely for some components: + // - We probably don't want to store a gigantic list of acronyms to the database. + // - The mod select overlay isn't built to handle duplicate mods/mods from all rulesets being shoved into it. + // Instead, freestyle inherently assumes this list is empty, and must be empty for server-side validation to pass. + FreeMods.Value = []; + } else - loadingLayer.Hide(); + { + // When disabling freestyle, enable freemods by default. + FreeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray(); + } + } + + private void onRulesetChanged(ValueChangedEvent ruleset) + { + // Todo: We can probably attempt to preserve across rulesets like the global mods do. + FreeMods.Value = []; + } + + private void updateFooterButtons() + { + if (freestyle.Value) + freeModSelect.Hide(); } - protected override bool SelectItem(PlaylistItem item) + /// + /// Removes invalid mods from and , + /// and updates mod selection overlays to display the new mods valid for selection. + /// + private void updateValidMods() + { + Mod[] validMods = Mods.Value.Where(isValidRequiredMod).ToArray(); + if (!validMods.SequenceEqual(Mods.Value)) + Mods.Value = validMods; + + Mod[] validFreeMods = FreeMods.Value.Where(isValidAllowedMod).ToArray(); + if (!validFreeMods.SequenceEqual(FreeMods.Value)) + FreeMods.Value = validFreeMods; + + modSelect.IsValidMod = isValidRequiredMod; + freeModSelect.IsValidMod = isValidAllowedMod; + } + + protected sealed override void OnStart() + { + var item = new PlaylistItem(Beatmap.Value.BeatmapInfo) + { + RulesetID = Ruleset.Value.OnlineID, + RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), + AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(), + Freestyle = freestyle.Value + }; + + selectItem(item); + } + + private bool selectItem(PlaylistItem item) { if (operationInProgress.Value) { - Logger.Log($"{nameof(SelectItem)} aborted due to {nameof(operationInProgress)}"); + Logger.Log($"{nameof(selectItem)} aborted due to {nameof(operationInProgress)}"); return false; } @@ -104,11 +261,6 @@ protected override bool SelectItem(PlaylistItem item) }, onError: _ => { selectionOperation.Dispose(); - - Schedule(() => - { - Carousel.AllowSelection = true; - }); }); } else @@ -120,6 +272,74 @@ protected override bool SelectItem(PlaylistItem item) return true; } - protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); + public override bool OnBackButton() + { + if (freeModSelect.State.Value == Visibility.Visible) + { + freeModSelect.Hide(); + return true; + } + + return base.OnBackButton(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + freeModSelect.Hide(); + return base.OnExiting(e); + } + + protected override ModSelectOverlay CreateModSelectOverlay() => modSelect = new UserModSelectOverlay(OverlayColourScheme.Plum) + { + IsValidMod = isValidRequiredMod + }; + + public override IReadOnlyList CreateFooterButtons() + { + var buttons = base.CreateFooterButtons().ToList(); + + buttons.Single(i => i is FooterButtonMods).TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip; + + buttons.InsertRange(buttons.FindIndex(b => b is FooterButtonMods) + 1, + [ + new FooterButtonFreeMods(freeModSelect) + { + FreeMods = { BindTarget = FreeMods }, + Freestyle = { BindTarget = freestyle } + }, + new FooterButtonFreestyle + { + Freestyle = { BindTarget = freestyle } + } + ]); + + return buttons; + } + + /// + /// Checks whether a given is valid to be selected as a required mod. + /// + /// The to check. + private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, freestyle.Value); + + /// + /// Checks whether a given is valid to be selected as an allowed mod. + /// + /// The to check. + private bool isValidAllowedMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, freestyle.Value) + // Mod must not be contained in the required mods. + && Mods.Value.All(m => m.Acronym != mod.Acronym) + // Mod must be compatible with all the required mods. + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + freeModSelectOverlayRegistration?.Dispose(); + } + + bool ISongSelect.CanPresentScore => false; + + void ISongSelect.PresentScore(ScoreInfo score, ScorePresentType presentType) { } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 16c6a46a9c98..40c1309e90a8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -510,7 +510,7 @@ private void onActivePlaylistItemChanged() { MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; - var newBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID); + var newBeatmap = beatmapManager.QueryOnlineBeatmapId(item.BeatmapID); if (!Beatmap.Value.BeatmapSetInfo.Equals(newBeatmap?.BeatmapSet)) this.MakeCurrent(); @@ -652,7 +652,7 @@ private void updateGameplayState() // Update global gameplay state to correspond to the new selection. // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info - var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", gameplayBeatmapId); + var localBeatmap = beatmapManager.QueryOnlineBeatmapId(gameplayBeatmapId); Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); Ruleset.Value = ruleset; Mods.Value = client.LocalUser.Mods.Concat(item.RequiredMods).Select(m => m.ToMod(rulesetInstance)).ToArray(); @@ -722,7 +722,7 @@ public void ShowUserStyleSelect() return; MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; - this.Push(new MultiplayerMatchFreestyleSelect(room, new PlaylistItem(item))); + this.Push(new MultiplayerMatchFreestyleSelect(new PlaylistItem(item))); } /// @@ -878,7 +878,7 @@ private bool ensureExitConfirmed() confirmDialog.PerformOkAction(); else { - dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + dialogOverlay.Push(new ConfirmExitMultiplayerMatchDialog(() => { ExitConfirmed = true; this.Exit(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 56120120d5b0..d7cbd02918d0 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -17,8 +17,8 @@ using osu.Game.Online.Rooms; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; using osuTK; @@ -56,7 +56,6 @@ public MultiplayerPlayer(Room room, PlaylistItem playlistItem, MultiplayerRoomUs { AllowPause = false, AllowRestart = false, - AllowSkipping = room.AutoSkip, AutomaticallySkipIntro = room.AutoSkip, ShowLeaderboard = true, }) @@ -121,6 +120,7 @@ protected override void LoadAsyncComplete() client.GameplayStarted += onGameplayStarted; client.ResultsReady += onResultsReady; + client.VoteToSkipIntroPassed += onVoteToSkipIntroPassed; ScoreProcessor.HasCompleted.BindValueChanged(_ => { @@ -148,6 +148,8 @@ protected override void LoadComplete() Debug.Assert(client.Room != null); } + protected override SkipOverlay CreateSkipOverlay(double startTime) => new MultiplayerSkipOverlay(startTime); + protected override void StartGameplay() { // We can enter this screen one of two ways: @@ -219,6 +221,24 @@ protected override async Task PrepareScoreForResultsAsync(Score score) await Task.WhenAny(resultsReady.Task, Task.Delay(TimeSpan.FromSeconds(60))).ConfigureAwait(false); } + protected override void RequestIntroSkip() + { + // If the room is set up such that the intro is automatically skipped, there's no need to vote on it. + if (Configuration.AutomaticallySkipIntro) + { + base.RequestIntroSkip(); + return; + } + + // No base call because we aren't skipping yet. + client.VoteToSkipIntro().FireAndForget(); + } + + private void onVoteToSkipIntroPassed() + { + Schedule(() => PerformIntroSkip(true)); + } + protected override ResultsScreen CreateResults(ScoreInfo score) { Debug.Assert(Room.RoomID != null); @@ -242,6 +262,7 @@ protected override void Dispose(bool isDisposing) { client.GameplayStarted -= onGameplayStarted; client.ResultsReady -= onResultsReady; + client.VoteToSkipIntroPassed -= onVoteToSkipIntroPassed; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs index dd9cb568628f..b13069a43658 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs @@ -15,6 +15,8 @@ public partial class MultiplayerPlayerLoader : PlayerLoader { public bool GameplayPassed => player?.GameplayState.HasPassed == true; + public override bool AllowUserExit => false; + [Resolved] private MultiplayerClient multiplayerClient { get; set; } = null!; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs index a2b9db5a0691..f2cbc41d16a9 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs @@ -18,7 +18,7 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Screens.Play; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osuTK; using osuTK.Graphics; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerSkipOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerSkipOverlay.cs new file mode 100644 index 000000000000..e44cb16f8ef0 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerSkipOverlay.cs @@ -0,0 +1,325 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerSkipOverlay : SkipOverlay + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private Button skipButton = null!; + + public MultiplayerSkipOverlay(double startTime) + : base(startTime) + { + } + + protected override OsuClickableContainer CreateButton(IBindable inSkipPeriod) => skipButton = new Button + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + InSkipPeriod = { BindTarget = inSkipPeriod }, + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + skipButton.Enabled.BindValueChanged(e => + { + RemainingTimeBox.Colour = e.NewValue ? colours.Orange3 : Button.COLOUR_GRAY; + }, true); + + client.UserLeft += onUserLeft; + client.UserStateChanged += onUserStateChanged; + client.UserVotedToSkipIntro += onUserVotedToSkipIntro; + + updateCount(); + } + + private void onUserLeft(MultiplayerRoomUser user) => Schedule(updateCount); + + private void onUserStateChanged(MultiplayerRoomUser user, MultiplayerUserState state) => Schedule(updateCount); + + private void onUserVotedToSkipIntro(int userId, bool voted) => Schedule(() => + { + FadingContent.TriggerShow(); + updateCount(); + }); + + private void updateCount() + { + if (client.Room == null || client.Room.Settings.AutoSkip) + return; + + int countTotal = client.Room.Users.Count(u => u.State == MultiplayerUserState.Playing); + int countSkipped = client.Room.Users.Count(u => u.State == MultiplayerUserState.Playing && u.VotedToSkipIntro); + int countRequired = countTotal / 2 + 1; + + skipButton.SkippedCount.Value = Math.Min(countRequired, countSkipped); + skipButton.RequiredCount.Value = countRequired; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.UserLeft -= onUserLeft; + client.UserStateChanged -= onUserStateChanged; + client.UserVotedToSkipIntro -= onUserVotedToSkipIntro; + } + } + + public partial class Button : OsuClickableContainer + { + private const float chevron_y = 0.4f; + private const float secondary_y = 0.7f; + + public static readonly Color4 COLOUR_GRAY = OsuColour.Gray(0.4f); + + private Box background = null!; + private Box box = null!; + private TrianglesV2 triangles = null!; + private OsuSpriteText countText = null!; + private OsuSpriteText skipText = null!; + private AspectContainer aspect = null!; + + private FillFlowContainer chevrons = null!; + + private Sample sampleConfirm = null!; + + public readonly BindableInt SkippedCount = new BindableInt(); + public readonly BindableInt RequiredCount = new BindableInt(); + public readonly BindableBool InSkipPeriod = new BindableBool(); + + private readonly BindableBool clicked = new BindableBool(); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public Button() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleConfirm = audio.Samples.Get(@"UI/submit-select"); + + Children = new Drawable[] + { + background = new Box + { + Alpha = 0.2f, + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + aspect = new AspectContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Height = 0.6f, + Masking = true, + CornerRadius = 15, + Children = new Drawable[] + { + box = new Box + { + RelativeSizeAxes = Axes.Both, + }, + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + }, + countText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + RelativePositionAxes = Axes.Y, + Y = 0.35f, + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 24), + Origin = Anchor.Centre, + }, + chevrons = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + RelativePositionAxes = Axes.Y, + AutoSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Children = new[] + { + new SpriteIcon { Size = new Vector2(15), Shadow = true, Icon = FontAwesome.Solid.ChevronRight }, + new SpriteIcon { Size = new Vector2(15), Shadow = true, Icon = FontAwesome.Solid.ChevronRight }, + new SpriteIcon { Size = new Vector2(15), Shadow = true, Icon = FontAwesome.Solid.ChevronRight }, + } + }, + skipText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + RelativePositionAxes = Axes.Y, + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 12), + Origin = Anchor.Centre, + Text = @"SKIP", + Y = secondary_y, + }, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SkippedCount.BindValueChanged(_ => updateCount()); + RequiredCount.BindValueChanged(_ => updateCount(), true); + + InSkipPeriod.BindValueChanged(_ => updateEnabledState()); + clicked.BindValueChanged(_ => updateEnabledState(), true); + + Enabled.BindValueChanged(_ => updateColours(), true); + + FinishTransforms(true); + } + + private void updateEnabledState() => Enabled.Value = InSkipPeriod.Value && !clicked.Value; + + private void updateChevronsSpacing() + { + if (SkippedCount.Value > 0 && RequiredCount.Value > 1) + chevrons.TransformSpacingTo(new Vector2(-5f), 500, Easing.OutQuint); + else + chevrons.TransformSpacingTo(IsHovered ? new Vector2(5f) : new Vector2(0f), 500, Easing.OutQuint); + } + + private void updateCount() + { + if (SkippedCount.Value > 0 && RequiredCount.Value > 1) + { + countText.FadeIn(300, Easing.OutQuint); + countText.Text = $"{SkippedCount.Value} / {RequiredCount.Value}"; + + chevrons.ScaleTo(0.5f, 300, Easing.OutQuint) + .MoveTo(new Vector2(-11, secondary_y), 300, Easing.OutQuint); + + skipText.MoveToX(11f, 300, Easing.OutQuint); + } + else + { + countText.FadeOut(300, Easing.OutQuint); + + chevrons.ScaleTo(1f, 300, Easing.OutQuint) + .MoveTo(new Vector2(0, chevron_y), 300, Easing.OutQuint); + + skipText.MoveToX(0f, 300, Easing.OutQuint); + } + + updateChevronsSpacing(); + updateColours(); + } + + private void updateColours() + { + if (!Enabled.Value) + { + box.FadeColour(COLOUR_GRAY, 500, Easing.OutQuint); + triangles.FadeColour(ColourInfo.GradientVertical(COLOUR_GRAY.Lighten(0.2f), COLOUR_GRAY), 500, Easing.OutQuint); + } + else + { + box.FadeColour(IsHovered ? colours.Orange3.Lighten(0.2f) : colours.Orange3, 500, Easing.OutQuint); + triangles.FadeColour(ColourInfo.GradientVertical(colours.Orange3.Lighten(0.2f), colours.Orange3), 500, Easing.OutQuint); + } + } + + protected override bool OnHover(HoverEvent e) + { + if (Enabled.Value) + { + updateChevronsSpacing(); + updateColours(); + background.FadeTo(0.4f, 500, Easing.OutQuint); + } + + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateChevronsSpacing(); + updateColours(); + background.FadeTo(0.2f, 500, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (Enabled.Value) + aspect.ScaleTo(0.75f, 2000, Easing.OutQuint); + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (Enabled.Value) + aspect.ScaleTo(1, 1000, Easing.OutElastic); + base.OnMouseUp(e); + } + + protected override bool OnClick(ClickEvent e) + { + if (!Enabled.Value) + return false; + + sampleConfirm.Play(); + + box.FlashColour(Color4.White, 500, Easing.OutQuint); + aspect.ScaleTo(1.2f, 2000, Easing.OutQuint); + + base.OnClick(e); + + clicked.Value = true; + return true; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + countText.Scale = new Vector2(Math.Min(0.85f * aspect.DrawWidth / countText.DrawWidth, 1)); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 19868082fa0d..c9804a1bf8f0 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -245,7 +245,7 @@ private void updateUser() userFlag.CountryCode = user?.CountryCode ?? default; teamFlagContainer.Child = new UpdateableTeamFlag(user?.Team) { - Size = new Vector2(40, 20) + Size = new Vector2(40, 20), }; username.Text = user?.Username ?? string.Empty; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs index 282430d7445d..1550cbb8df18 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs @@ -106,14 +106,12 @@ private void updateUser() clickableContent.TooltipText = "Change team"; } - // reset to ensure samples don't play - DisplayedTeam = null; - updateState(); + updateState(false); } - private void onRoomUpdated() => Scheduler.AddOnce(updateState); + private void onRoomUpdated() => Scheduler.AddOnce(() => updateState(true)); - private void updateState() + private void updateState(bool playSamples) { // we don't have a way of knowing when an individual user's state has updated, so just handle on RoomUpdated for now. @@ -129,7 +127,7 @@ private void updateState() // only play the sample if an already valid team changes to another valid team. // this avoids playing a sound for each user if the match type is changed to/from a team mode. - if (newTeam != null && DisplayedTeam != null) + if (playSamples && newTeam != null && DisplayedTeam != null) sampleTeamSwap?.Play(); DisplayedTeam = newTeam; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index e557c6821b86..070cda327ac2 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -8,8 +8,8 @@ using osu.Game.Beatmaps; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index fb9343c51903..ad7966587e5b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -6,16 +6,18 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Online.Spectator; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Spectate; using osu.Game.Users; using osuTK; @@ -63,6 +65,9 @@ public partial class MultiSpectatorScreen : SpectatorScreen private readonly Room room; + private PlayerSettingsOverlay playerSettingsOverlay = null!; + private Bindable configSettingsOverlay = null!; + /// /// Creates a new . /// @@ -78,8 +83,10 @@ public MultiSpectatorScreen(Room room, MultiplayerRoomUser[] users) } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { + configSettingsOverlay = config.GetBindable(OsuSetting.ReplaySettingsOverlay); + FillFlowContainer leaderboardFlow; Container scoreDisplayContainer; @@ -131,7 +138,10 @@ private void load() { ReadyToStart = performInitialSeek, }, - new PlayerSettingsOverlay() + playerSettingsOverlay = new PlayerSettingsOverlay + { + Alpha = 0, + } }; for (int i = 0; i < Users.Count; i++) @@ -172,6 +182,16 @@ protected override void LoadComplete() // Start with adjustments from the first player to keep a sane state. bindAudioAdjustments(instances.First()); + + configSettingsOverlay.BindValueChanged(_ => updateVisibility(), true); + } + + private void updateVisibility() + { + if (configSettingsOverlay.Value) + playerSettingsOverlay.Show(); + else + playerSettingsOverlay.Hide(); } protected override void Update() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.Cell.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.Cell.cs index d1ba21411723..edb9b5465504 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.Cell.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.Cell.cs @@ -47,6 +47,13 @@ public Cell(int facadeIndex, Drawable content, Facade facade) Masking = true; CornerRadius = 5; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 10, + Colour = Colour4.Black.Opacity(0.2f), + }; } protected override void Update() diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayBeatmapAvailabilityTracker.cs index ae0b4a9943af..1bb67cc3afc5 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayBeatmapAvailabilityTracker.cs @@ -17,7 +17,6 @@ using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; -using Realms; namespace osu.Game.Screens.OnlinePlay { @@ -154,7 +153,8 @@ void updateAvailability() } IQueryable queryBeatmap() => - realm.Realm.All().Filter("OnlineID == $0 && MD5Hash == $1 && BeatmapSet.DeletePending == false", beatmap.OnlineID, beatmap.MD5Hash); + realm.Realm.All() + .ForOnlineId(beatmap.OnlineID); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs index 13ac40639688..58b92e9f8ab8 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs @@ -6,45 +6,77 @@ using Humanizer; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.Rooms; using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Footer; using osu.Game.Screens.Select; -using osu.Game.Users; namespace osu.Game.Screens.OnlinePlay { - public abstract partial class OnlinePlayFreestyleSelect : SongSelect, IOnlinePlaySubScreen, IHandlePresentBeatmap + public abstract partial class OnlinePlayFreestyleSelect : SongSelect, IHandlePresentBeatmap, IOnlinePlaySubScreen, ISongSelect { - public string ShortTitle => "style selection"; + private readonly PlaylistItem item; + public string ShortTitle => "style selection"; public override string Title => ShortTitle.Humanize(); + public bool ShowHeaderLine => false; - public override bool AllowEditing => false; + protected abstract void StartAction(); - protected override UserActivity InitialActivity => new UserActivity.InLobby(room); + [Resolved] + private RealmAccess realm { get; set; } = null!; - private readonly Room room; - private readonly PlaylistItem item; - - protected OnlinePlayFreestyleSelect(Room room, PlaylistItem item) + protected OnlinePlayFreestyleSelect(PlaylistItem item) { - this.room = room; this.item = item; Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; + + SupportScoping = false; } [BackgroundDependencyLoader] private void load() { - LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; + FilterControl.ApplyRequiredCriteria = applyRestrictions; + } + + protected override void OnStart() + { + if (isValidForSelection()) + StartAction(); + } + + private void applyRestrictions(FilterCriteria criteria) + { + double itemLength = 0; + int beatmapSetId = 0; + + realm.Run(r => + { + int beatmapId = item.Beatmap.OnlineID; + BeatmapInfo? beatmap = r.All().FirstOrDefault(b => b.OnlineID == beatmapId); + + itemLength = beatmap?.Length ?? 0; + beatmapSetId = beatmap?.BeatmapSet?.OnlineID ?? 0; + }); + + // Must be from the same set as the playlist item. + criteria.BeatmapSetId = beatmapSetId; + criteria.HasOnlineID = true; + + // Must be within 30s of the playlist item. + criteria.Length.Min = itemLength - 30000; + criteria.Length.Max = itemLength + 30000; + criteria.Length.IsLowerInclusive = true; + criteria.Length.IsUpperInclusive = true; } - protected override bool OnStart() + private bool isValidForSelection() { FilterCriteria criteria = FilterControl.CreateCriteria(); @@ -78,61 +110,15 @@ protected override bool OnStart() return true; } - protected override FilterControl CreateFilterControl() => new DifficultySelectFilterControl(item); + public override IReadOnlyList CreateFooterButtons() => []; - protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() - { - // Required to create the drawable components. - base.CreateSongSelectFooterButtons(); - return Enumerable.Empty<(FooterButton, OverlayContainer?)>(); - } - - protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); - - public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) + void IHandlePresentBeatmap.PresentBeatmap(WorkingBeatmap workingBeatmap, RulesetInfo ruleset) { // This screen cannot present beatmaps. } - private partial class DifficultySelectFilterControl : FilterControl - { - private readonly PlaylistItem item; - - [Resolved] - private RealmAccess realm { get; set; } = null!; + bool ISongSelect.CanPresentScore => false; - public DifficultySelectFilterControl(PlaylistItem item) - { - this.item = item; - } - - public override FilterCriteria CreateCriteria() - { - var criteria = base.CreateCriteria(); - - double itemLength = 0; - int beatmapSetId = 0; - - realm.Run(r => - { - int beatmapId = item.Beatmap.OnlineID; - BeatmapInfo? beatmap = r.All().FirstOrDefault(b => b.OnlineID == beatmapId); - - itemLength = beatmap?.Length ?? 0; - beatmapSetId = beatmap?.BeatmapSet?.OnlineID ?? 0; - }); - - // Must be from the same set as the playlist item. - criteria.BeatmapSetId = beatmapSetId; - criteria.HasOnlineID = true; - - // Must be within 30s of the playlist item. - criteria.Length.Min = itemLength - 30000; - criteria.Length.Max = itemLength + 30000; - criteria.Length.IsLowerInclusive = true; - criteria.Length.IsUpperInclusive = true; - return criteria; - } - } + void ISongSelect.PresentScore(ScoreInfo score, ScorePresentType presentType) { } } } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs deleted file mode 100644 index bb6d75fa3bd5..000000000000 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ /dev/null @@ -1,274 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using Humanizer; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Screens; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Online.Rooms; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Select; -using osu.Game.Users; -using osu.Game.Utils; -using osu.Game.Localisation; - -namespace osu.Game.Screens.OnlinePlay -{ - public abstract partial class OnlinePlaySongSelect : SongSelect, IOnlinePlaySubScreen - { - public string ShortTitle => "song selection"; - - public override string Title => ShortTitle.Humanize(); - - public override bool AllowEditing => false; - - [Resolved] - private RulesetStore rulesets { get; set; } = null!; - - [Resolved] - private BeatmapManager beatmapManager { get; set; } = null!; - - protected override UserActivity InitialActivity => new UserActivity.InLobby(room); - - protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); - protected readonly Bindable Freestyle = new Bindable(true); - - private readonly Room room; - private readonly PlaylistItem? initialItem; - private readonly FreeModSelectOverlay freeModSelect; - private FooterButton freeModsFooterButton = null!; - - private IDisposable? freeModSelectOverlayRegistration; - - /// - /// Creates a new . - /// - /// The room. - /// An optional initial to use for the initial beatmap/ruleset/mods. - /// If null, the last in the room will be used. - protected OnlinePlaySongSelect(Room room, PlaylistItem? initialItem = null) - { - this.room = room; - this.initialItem = initialItem ?? room.Playlist.LastOrDefault(); - - Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; - - freeModSelect = new FreeModSelectOverlay - { - SelectedMods = { BindTarget = FreeMods }, - IsValidMod = isValidAllowedMod, - }; - } - - [BackgroundDependencyLoader] - private void load() - { - LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; - LoadComponent(freeModSelect); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - if (initialItem != null) - { - // Prefer using a local databased beatmap lookup since OnlineId may be -1 for an invalid beatmap selection. - BeatmapInfo? beatmapInfo = initialItem.Beatmap as BeatmapInfo; - - // And in the case that this isn't a local databased beatmap, query by online ID. - if (beatmapInfo == null) - { - int onlineId = initialItem.Beatmap.OnlineID; - beatmapInfo = beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId); - } - - if (beatmapInfo != null) - Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo); - - RulesetInfo? ruleset = rulesets.GetRuleset(initialItem.RulesetID); - - if (ruleset != null) - { - Ruleset.Value = ruleset; - - var rulesetInstance = ruleset.CreateInstance(); - Debug.Assert(rulesetInstance != null); - - // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. - // Similarly, freeMods is currently empty but should only contain the allowed mods. - Mods.Value = initialItem.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - FreeMods.Value = initialItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - } - - Freestyle.Value = initialItem.Freestyle; - } - - Mods.BindValueChanged(onGlobalModsChanged); - Ruleset.BindValueChanged(onRulesetChanged); - Freestyle.BindValueChanged(onFreestyleChanged); - - freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelect); - - updateFooterButtons(); - updateValidMods(); - } - - private void onFreestyleChanged(ValueChangedEvent enabled) - { - updateFooterButtons(); - updateValidMods(); - - if (enabled.NewValue) - { - // Freestyle allows all mods to be selected as freemods. This does not play nicely for some components: - // - We probably don't want to store a gigantic list of acronyms to the database. - // - The mod select overlay isn't built to handle duplicate mods/mods from all rulesets being shoved into it. - // Instead, freestyle inherently assumes this list is empty, and must be empty for server-side validation to pass. - FreeMods.Value = []; - } - else - { - // When disabling freestyle, enable freemods by default. - FreeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray(); - } - } - - private void onGlobalModsChanged(ValueChangedEvent> mods) - { - updateValidMods(); - } - - private void onRulesetChanged(ValueChangedEvent ruleset) - { - // Todo: We can probably attempt to preserve across rulesets like the global mods do. - FreeMods.Value = []; - } - - private void updateFooterButtons() - { - if (Freestyle.Value) - { - freeModsFooterButton.Enabled.Value = false; - freeModSelect.Hide(); - } - else - freeModsFooterButton.Enabled.Value = true; - } - - /// - /// Removes invalid mods from and , - /// and updates mod selection overlays to display the new mods valid for selection. - /// - private void updateValidMods() - { - Mod[] validMods = Mods.Value.Where(isValidRequiredMod).ToArray(); - if (!validMods.SequenceEqual(Mods.Value)) - Mods.Value = validMods; - - Mod[] validFreeMods = FreeMods.Value.Where(isValidAllowedMod).ToArray(); - if (!validFreeMods.SequenceEqual(FreeMods.Value)) - FreeMods.Value = validFreeMods; - - ModSelect.IsValidMod = isValidRequiredMod; - freeModSelect.IsValidMod = isValidAllowedMod; - } - - protected sealed override bool OnStart() - { - var item = new PlaylistItem(Beatmap.Value.BeatmapInfo) - { - RulesetID = Ruleset.Value.OnlineID, - RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), - AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(), - Freestyle = Freestyle.Value - }; - - return SelectItem(item); - } - - /// - /// Invoked when the user has requested a selection of a beatmap. - /// - /// The resultant . This item has not yet been added to the 's. - /// true if a selection occurred. - protected abstract bool SelectItem(PlaylistItem item); - - public override bool OnBackButton() - { - if (freeModSelect.State.Value == Visibility.Visible) - { - freeModSelect.Hide(); - return true; - } - - return base.OnBackButton(); - } - - public override bool OnExiting(ScreenExitEvent e) - { - freeModSelect.Hide(); - return base.OnExiting(e); - } - - protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum) - { - IsValidMod = isValidRequiredMod - }; - - protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() - { - var baseButtons = base.CreateSongSelectFooterButtons().ToList(); - - baseButtons.Single(i => i.button is FooterButtonMods).button.TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip; - - baseButtons.InsertRange(baseButtons.FindIndex(b => b.button is FooterButtonMods) + 1, new (FooterButton, OverlayContainer?)[] - { - (freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) - { - FreeMods = { BindTarget = FreeMods }, - Freestyle = { BindTarget = Freestyle } - }, null), - (new FooterButtonFreestyle - { - Freestyle = { BindTarget = Freestyle } - }, null) - }); - - return baseButtons; - } - - /// - /// Checks whether a given is valid to be selected as a required mod. - /// - /// The to check. - private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, Freestyle.Value); - - /// - /// Checks whether a given is valid to be selected as an allowed mod. - /// - /// The to check. - private bool isValidAllowedMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, Freestyle.Value) - // Mod must not be contained in the required mods. - && Mods.Value.All(m => m.Acronym != mod.Acronym) - // Mod must be compatible with all the required mods. - && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - freeModSelectOverlayRegistration?.Dispose(); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs index 47629981f1cb..5a6878139709 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs @@ -10,6 +10,7 @@ using osu.Game.Collections; using osu.Game.Database; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -69,10 +70,14 @@ private void load() countAfter = c.BeatmapMD5Hashes.Count; }).ContinueWith(_ => Schedule(() => { + LocalisableString message; + if (countBefore == 0) - notifications?.Post(new SimpleNotification { Text = $"Created new collection \"{room.Name}\" with {countAfter} beatmaps." }); + message = NotificationsStrings.CollectionCreated(room.Name, countAfter); else - notifications?.Post(new SimpleNotification { Text = $"Added {countAfter - countBefore} beatmaps to collection \"{room.Name}\"." }); + message = NotificationsStrings.CollectionBeatmapsAdded(room.Name, countAfter - countBefore); + + notifications?.Post(new SimpleNotification { Text = message }); })); }; } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/AddToPlaylistFooterButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/AddToPlaylistFooterButton.cs new file mode 100644 index 000000000000..622b87503fda --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/AddToPlaylistFooterButton.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class AddToPlaylistFooterButton : ShearedButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Width = 220; + + DarkerColour = colours.Blue3; + LighterColour = colours.Blue1; + + ButtonContent.Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = -10, + Font = OsuFont.TorusAlternate.With(size: 17), + Text = OnlinePlayStrings.FooterButtonPlaylistAdd, + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = 35, + Font = OsuFont.TorusAlternate.With(size: 20), + Shadow = false, + Text = "+", + UseFullGlyphHeight = false, + }, + }; + } + + public void Appear() + { + FinishTransforms(); + + this.MoveToY(150f) + .FadeOut() + .MoveToY(0f, 240, Easing.OutCubic) + .FadeIn(240, Easing.OutCubic); + } + + public TransformSequence Disappear() + { + FinishTransforms(); + + return this.FadeOut(240, Easing.InOutCubic) + .MoveToY(150f, 240, Easing.InOutCubic); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/ClosePlaylistDialog.cs b/osu.Game/Screens/OnlinePlay/Playlists/ClosePlaylistDialog.cs index 08fed037d3a1..4dbe08e93e93 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/ClosePlaylistDialog.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/ClosePlaylistDialog.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Game.Localisation; using osu.Game.Online.Rooms; using osu.Game.Overlays.Dialog; @@ -11,7 +12,7 @@ public partial class ClosePlaylistDialog : DeletionDialog { public ClosePlaylistDialog(Room room, Action closeAction) { - HeaderText = "Are you sure you want to close the following playlist:"; + HeaderText = DialogStrings.ClosePlaylistHeaderText; BodyText = room.Name; DangerousAction = closeAction; } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs index e99429960680..df5a9db8e75b 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs @@ -62,8 +62,7 @@ protected PlaylistItemResultsScreen(ScoreInfo? score, long roomId, PlaylistItem [BackgroundDependencyLoader] private void load() { - var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", - PlaylistItem.Beatmap.OnlineID); + var localBeatmap = beatmapManager.QueryOnlineBeatmapId(PlaylistItem.Beatmap.OnlineID); itemBeatmap = beatmapManager.GetWorkingBeatmap(localBeatmap); AddInternal(new Container diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 69a1e3b76389..ecd45370fc37 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -12,8 +12,8 @@ using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Screens.OnlinePlay.Playlists diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 9c0363f40ed1..378410d77df0 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -241,7 +241,7 @@ private void load(OverlayColourProvider colourProvider, OsuColour colours) { RelativeSizeAxes = Axes.X, Height = 40, - Text = "Edit playlist", + Text = "+ Add more beatmaps", Action = () => EditPlaylist?.Invoke() } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index fdda6f6c8551..646da4e96779 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -609,7 +609,7 @@ private void updateGameplayState() // Update global gameplay state to correspond to the new selection. // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info - var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", gameplayBeatmap.OnlineID); + var localBeatmap = beatmapManager.QueryOnlineBeatmapId(gameplayBeatmap.OnlineID); Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); Ruleset.Value = gameplayRuleset; Mods.Value = UserMods.Value.Concat(item.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToArray(); @@ -696,7 +696,7 @@ private void showUserStyleSelect() if (!this.IsCurrentScreen() || SelectedItem.Value == null) return; - this.Push(new PlaylistsRoomFreestyleSelect(room, SelectedItem.Value) + this.Push(new PlaylistsRoomFreestyleSelect(SelectedItem.Value) { Beatmap = { BindTarget = UserBeatmap }, Ruleset = { BindTarget = UserRuleset } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.PlaylistTray.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.PlaylistTray.cs new file mode 100644 index 000000000000..b46759915315 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.PlaylistTray.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.ComponentModel; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osuTK; +using Container = osu.Framework.Graphics.Containers.Container; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class PlaylistsSongSelect + { + public partial class PlaylistTray : CompositeDrawable + { + private readonly Room room; + + private OsuScrollContainer scroll = null!; + private FillFlowContainer flow = null!; + private OsuSpriteText text = null!; + + private const float item_width = 250; + + public PlaylistTray(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Size = new Vector2(500, 75); + + Masking = true; + CornerRadius = 20; + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = colourProvider.Background6.Opacity(0.2f), + Offset = new Vector2(2), + Radius = 8, + }; + + InternalChild = new BufferedContainer(pixelSnapping: true) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + }, + new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = DrawableRoomPlaylistItem.HEIGHT, + Padding = new MarginPadding { Horizontal = 10 }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + text = new OsuSpriteText + { + Font = OsuFont.Style.Heading2, + }, + new OsuSpriteText + { + Y = 20, + Font = OsuFont.Style.Caption2, + Text = OnlinePlayStrings.PlaylistTrayDescription + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + scroll = new OsuScrollContainer(Direction.Horizontal) + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Padding = new MarginPadding { Left = item_width }, + Spacing = new Vector2(5), + Direction = FillDirection.Horizontal + } + }, + new Box + { + Colour = ColourInfo.GradientHorizontal(colourProvider.Background3, colourProvider.Background3.Opacity(0)), + RelativeSizeAxes = Axes.Y, + X = -1, + Width = 60, + }, + } + }, + }, + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; + updateRoomPlaylist(); + + this.FadeOut(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Playlist)) + updateRoomPlaylist(); + } + + private void updateRoomPlaylist() + { + if (room.Playlist.Count > 0) + { + var newItem = new DrawableRoomPlaylistItem(room.Playlist[^1], loadImmediately: true) + { + RelativeSizeAxes = Axes.None, + Width = item_width, + AllowReordering = false, + }; + + if (flow.Count > 1) + flow[0].Expire(); + + flow.Add(newItem); + + if (scroll.IsLoaded) + scroll.ScrollToStart(animated: false); + ScheduleAfterChildren(() => scroll.ScrollToEnd()); + + Scheduler.AddDelayed(() => text.Text = OnlinePlayStrings.PlaylistTrayItems(room.Playlist.Count), 100); + } + + this.FadeIn(200) + .Delay(2000) + .FadeOut(200); + } + + // Disallow the user from interacting with the scrolling elements. + public override bool PropagatePositionalInputSubTree => false; + public override bool PropagateNonPositionalInputSubTree => false; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 84446ed0cfe3..0fa0266cc952 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -1,46 +1,269 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.Linq; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Screens; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.Footer; using osu.Game.Screens.Select; +using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Playlists { - public partial class PlaylistsSongSelect : OnlinePlaySongSelect + public partial class PlaylistsSongSelect : SongSelect, IOnlinePlaySubScreen, ISongSelect { + public string ShortTitle => "song selection"; + + public override string Title => ShortTitle.Humanize(); + + protected readonly Bindable Freestyle = new Bindable(true); + private readonly Bindable> freeMods = new Bindable>([]); + + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + private readonly AddToPlaylistFooterButton addToPlaylistFooterButton; + private readonly Room room; + private ModSelectOverlay modSelect = null!; + private FreeModSelectOverlay freeModSelect = null!; + + private IDisposable? modSelectOverlayRegistration; public PlaylistsSongSelect(Room room) - : base(room) { this.room = room; + + ShowOsuLogo = false; + + Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; + LeftPadding = new MarginPadding { Top = CORNER_RADIUS_HIDE_OFFSET + Header.HEIGHT }; + + addToPlaylistFooterButton = new AddToPlaylistFooterButton + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding + { + Bottom = OsuGame.SCREEN_EDGE_MARGIN, + Right = OsuGame.SCREEN_EDGE_MARGIN * 2 + }, + Alpha = 0, + Action = AddNewItem + }; } - protected override BeatmapDetailArea CreateBeatmapDetailArea() => new MatchBeatmapDetailArea(room) + [BackgroundDependencyLoader] + private void load() { - CreateNewItem = () => room.Playlist = room.Playlist.Append(createNewItem()).ToArray() - }; + AddInternal(new PlaylistTray(room) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding + { + Bottom = ScreenFooterButton.HEIGHT, + Right = OsuGame.SCREEN_EDGE_MARGIN + } + }); - protected override bool SelectItem(PlaylistItem item) + LoadComponent(freeModSelect = new FreeModSelectOverlay + { + SelectedMods = { BindTarget = freeMods }, + IsValidMod = isValidAllowedMod, + }); + } + + protected override void LoadComplete() { - if (room.Playlist.Count <= 1) - room.Playlist = [createNewItem()]; + base.LoadComplete(); + + modSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(freeModSelect); + + modSelect.State.BindValueChanged(onModSelectStateChanged, true); + freeModSelect.State.BindValueChanged(onModSelectStateChanged, true); + + Mods.BindValueChanged(onGlobalModsChanged); + Ruleset.BindValueChanged(onRulesetChanged); + Freestyle.BindValueChanged(onFreestyleChanged); - this.Exit(); - return true; + updateValidMods(); + + Footer?.Add(addToPlaylistFooterButton); + } + + public void AddNewItem() + { + room.Playlist = room.Playlist.Append(createItem()).ToArray(); } - private PlaylistItem createNewItem() => new PlaylistItem(Beatmap.Value.BeatmapInfo) + private void onModSelectStateChanged(ValueChangedEvent state) + { + if (state.NewValue == Visibility.Visible) + addToPlaylistFooterButton.Disappear(); + else + addToPlaylistFooterButton.Appear(); + } + + private void onGlobalModsChanged(ValueChangedEvent> mods) + { + updateValidMods(); + } + + private void onRulesetChanged(ValueChangedEvent ruleset) + { + // Todo: We can probably attempt to preserve across rulesets like the global mods do. + freeMods.Value = []; + } + + private void onFreestyleChanged(ValueChangedEvent enabled) + { + updateValidMods(); + + if (enabled.NewValue) + { + freeModSelect.Hide(); + + // Freestyle allows all mods to be selected as freemods. This does not play nicely for some components: + // - We probably don't want to store a gigantic list of acronyms to the database. + // - The mod select overlay isn't built to handle duplicate mods/mods from all rulesets being shoved into it. + // Instead, freestyle inherently assumes this list is empty, and must be empty for server-side validation to pass. + freeMods.Value = []; + } + else + { + // When disabling freestyle, enable freemods by default. + freeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray(); + } + } + + /// + /// Removes invalid mods from and , + /// and updates mod selection overlays to display the new mods valid for selection. + /// + private void updateValidMods() + { + Mod[] validMods = Mods.Value.Where(isValidRequiredMod).ToArray(); + if (!validMods.SequenceEqual(Mods.Value)) + Mods.Value = validMods; + + Mod[] validFreeMods = freeMods.Value.Where(isValidAllowedMod).ToArray(); + if (!validFreeMods.SequenceEqual(freeMods.Value)) + freeMods.Value = validFreeMods; + + modSelect.IsValidMod = isValidRequiredMod; + freeModSelect.IsValidMod = isValidAllowedMod; + } + + protected override void OnStart() + { + addToPlaylistFooterButton.TriggerClick(); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + addToPlaylistFooterButton.Appear(); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + + addToPlaylistFooterButton.Appear(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + + addToPlaylistFooterButton.Disappear(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + addToPlaylistFooterButton.Disappear().Expire(); + return false; + } + + public override IReadOnlyList CreateFooterButtons() + { + var buttons = base.CreateFooterButtons().ToList(); + + buttons.Single(i => i is FooterButtonMods).TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip; + + buttons.InsertRange(buttons.FindIndex(b => b is FooterButtonMods) + 1, + [ + new FooterButtonFreeMods(freeModSelect) + { + FreeMods = { BindTarget = freeMods }, + Freestyle = { BindTarget = Freestyle } + }, + new FooterButtonFreestyle + { + Freestyle = { BindTarget = Freestyle } + } + ]); + + return buttons; + } + + protected override ModSelectOverlay CreateModSelectOverlay() => modSelect = new UserModSelectOverlay(OverlayColourScheme.Plum) + { + IsValidMod = isValidRequiredMod + }; + + private PlaylistItem createItem() => new PlaylistItem(Beatmap.Value.BeatmapInfo) { ID = room.Playlist.Count == 0 ? 0 : room.Playlist.Max(p => p.ID) + 1, RulesetID = Ruleset.Value.OnlineID, RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), - AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(), + AllowedMods = freeMods.Value.Select(m => new APIMod(m)).ToArray(), Freestyle = Freestyle.Value }; + + /// + /// Checks whether a given is valid to be selected as a required mod. + /// + /// The to check. + private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, Freestyle.Value); + + /// + /// Checks whether a given is valid to be selected as an allowed mod. + /// + /// The to check. + private bool isValidAllowedMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, Freestyle.Value) + // Mod must not be contained in the required mods. + && Mods.Value.All(m => m.Acronym != mod.Acronym) + // Mod must be compatible with all the required mods. + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + modSelectOverlayRegistration?.Dispose(); + } + + bool ISongSelect.CanPresentScore => false; + + void ISongSelect.PresentScore(ScoreInfo score, ScorePresentType presentType) { } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFreestyleSelect.cs b/osu.Game/Screens/OnlinePlay/PlaylistsRoomFreestyleSelect.cs similarity index 72% rename from osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFreestyleSelect.cs rename to osu.Game/Screens/OnlinePlay/PlaylistsRoomFreestyleSelect.cs index 1f0f92aea289..6c16d917fa3c 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFreestyleSelect.cs +++ b/osu.Game/Screens/OnlinePlay/PlaylistsRoomFreestyleSelect.cs @@ -7,28 +7,24 @@ using osu.Game.Online.Rooms; using osu.Game.Rulesets; -namespace osu.Game.Screens.OnlinePlay.Playlists +namespace osu.Game.Screens.OnlinePlay { public partial class PlaylistsRoomFreestyleSelect : OnlinePlayFreestyleSelect { public new readonly Bindable Beatmap = new Bindable(); public new readonly Bindable Ruleset = new Bindable(); - public PlaylistsRoomFreestyleSelect(Room room, PlaylistItem item) - : base(room, item) + public PlaylistsRoomFreestyleSelect(PlaylistItem item) + : base(item) { } - protected override bool OnStart() + protected override void StartAction() { - if (!base.OnStart()) - return false; - Beatmap.Value = base.Beatmap.Value.BeatmapInfo; Ruleset.Value = base.Ruleset.Value; this.Exit(); - return true; } } } diff --git a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs index 23264c4518c9..f1df01b7434d 100644 --- a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs @@ -114,7 +114,10 @@ private void load(BeatmapDifficultyCache difficultyCache) Anchor = Anchor.Centre, FillMode = FillMode.Fill, }, - loading = new LoadingLayer(dimBackground: true, blockInput: false) + loading = new LoadingLayer(dimBackground: true) + { + BlockPositionalInput = false, + } } }, versionFlow = new FillFlowContainer diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index 2ae66a6dc4ba..73f887bfe6e0 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -10,10 +9,9 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; -using osu.Game.Beatmaps.ControlPoints; +using osu.Framework.Utils; using osu.Game.Beatmaps.Timing; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.Break; @@ -21,7 +19,7 @@ namespace osu.Game.Screens.Play { - public partial class BreakOverlay : BeatSyncedContainer + public partial class BreakOverlay : Container { /// /// The duration of the break overlay fading. @@ -51,12 +49,6 @@ public BreakOverlay(ScoreProcessor scoreProcessor) this.scoreProcessor = scoreProcessor; RelativeSizeAxes = Axes.Both; - MinimumBeatLength = 200; - - // Doesn't play well with pause/unpause. - // This might mean that some beats don't animate if the user is running <60fps, but we'll deal with that if anyone notices. - AllowMistimedEventFiring = false; - Child = fadeContainer = new Container { Alpha = 0, @@ -142,30 +134,12 @@ protected override void Update() { base.Update(); + remainingTimeBox.Width = (float)Interpolation.DampContinuously(remainingTimeBox.Width, remainingTimeForCurrentPeriod, 40, Math.Abs(Time.Elapsed)); remainingTimeBox.Height = Math.Min(8, remainingTimeBox.DrawWidth); - - // Keep things simple by resetting beat synced transforms on a rewind. - if (Clock.ElapsedFrameTime < 0) - { - remainingTimeBox.ClearTransforms(targetMember: nameof(Width)); - remainingTimeBox.Width = remainingTimeForCurrentPeriod; - } - } - - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) - { - base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); - - if (currentPeriod.Value == null) - return; - - float timeBoxTargetWidth = (float)Math.Max(0, remainingTimeForCurrentPeriod - timingPoint.BeatLength / currentPeriod.Value.Value.Duration); - remainingTimeBox.ResizeWidthTo(timeBoxTargetWidth, timingPoint.BeatLength * 3.5, Easing.OutQuint); } private void updateDisplay(ValueChangedEvent period) { - FinishTransforms(true); Scheduler.CancelDelayedTasks(); if (period.NewValue == null) @@ -180,12 +154,10 @@ private void updateDisplay(ValueChangedEvent period) remainingTimeAdjustmentBox .ResizeWidthTo(remaining_time_container_max_size, BREAK_FADE_DURATION, Easing.OutQuint) - .Delay(b.Duration - BREAK_FADE_DURATION) + .Delay(b.Duration) .ResizeWidthTo(0); - remainingTimeBox.ResizeWidthTo(remainingTimeForCurrentPeriod); - - remainingTimeCounter.CountTo(b.Duration).CountTo(0, b.Duration); + remainingTimeCounter.CountTo(b.Duration + BREAK_FADE_DURATION).CountTo(0, b.Duration + BREAK_FADE_DURATION); remainingTimeCounter.MoveToX(-50) .MoveToX(0, BREAK_FADE_DURATION, Easing.OutQuint); @@ -193,7 +165,7 @@ private void updateDisplay(ValueChangedEvent period) info.MoveToX(50) .MoveToX(0, BREAK_FADE_DURATION, Easing.OutQuint); - using (BeginDelayedSequence(b.Duration - BREAK_FADE_DURATION)) + using (BeginDelayedSequence(b.Duration)) { fadeContainer.FadeOut(BREAK_FADE_DURATION); breakArrows.Hide(BREAK_FADE_DURATION); diff --git a/osu.Game/Screens/Play/FailOverlay.cs b/osu.Game/Screens/Play/FailOverlay.cs index 4a0a6f573cf9..f5f1bf37e679 100644 --- a/osu.Game/Screens/Play/FailOverlay.cs +++ b/osu.Game/Screens/Play/FailOverlay.cs @@ -18,7 +18,7 @@ namespace osu.Game.Screens.Play { public partial class FailOverlay : GameplayMenuOverlay { - public Func>? SaveReplay; + public Func>? SaveReplay { get; init; } public override LocalisableString Header => GameplayMenuOverlayStrings.FailedHeader; diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 7d946dc6786f..d4c40c78ae3a 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -22,6 +22,7 @@ using osuTK; using osuTK.Graphics; using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Utils; namespace osu.Game.Screens.Play @@ -236,7 +237,7 @@ private void updateInfoText() if (gameplayState != null) { playInfoText.NewLine(); - playInfoText.AddText(SongSelectStrings.Accuracy); + playInfoText.AddText(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy); playInfoText.AddText(": "); playInfoText.AddText(gameplayState!.ScoreProcessor.Accuracy.Value.FormatAccuracy(), cp => cp.Font = cp.Font.With(weight: FontWeight.Bold)); } diff --git a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs index ddb926ebf1cd..cd3ee730187e 100644 --- a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs @@ -12,7 +12,7 @@ using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Localisation.SkinComponents; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; diff --git a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs index 339488e5d07d..f7220e98ac8c 100644 --- a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs @@ -16,7 +16,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online.API; using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Users; using osu.Game.Users.Drawables; using osu.Game.Utils; @@ -357,7 +357,7 @@ private void updatePanelState() else if (Tracked) { widthExtension = true; - setPanelColourAsTracked(); + setPanelColour(BackgroundColour ?? colours.Orange2); } else if (isFriend) { @@ -380,13 +380,6 @@ private void setPanelColour(Color4 baseColour) scorePanel.BorderColour = ColourInfo.GradientVertical(baseColour.Opacity(0.2f), baseColour); } - private void setPanelColourAsTracked() - { - leftLayerGradient.Colour = ColourInfo.GradientVertical(colours.Blue2.Opacity(0.3f), colours.Blue2); - rightLayerGradient.Colour = ColourInfo.GradientVertical(colours.Blue4.Opacity(0.25f), colours.Blue3.Opacity(0.6f)); - scorePanel.BorderColour = ColourInfo.GradientVertical(colours.Blue1.Opacity(0.2f), colours.Blue1); - } - protected override void Update() { base.Update(); diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 96e937fda77d..2772e7514c07 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -21,6 +21,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osuTK; using osuTK.Graphics; @@ -44,8 +45,12 @@ public partial class HoldForMenuButton : FillFlowContainer private Bindable alwaysShow; - public HoldForMenuButton() + private readonly bool isDangerousAction; + + public HoldForMenuButton(bool isDangerousAction = false) { + this.isDangerousAction = isDangerousAction; + Direction = FillDirection.Horizontal; Spacing = new Vector2(20, 0); Margin = new MarginPadding(10); @@ -53,8 +58,8 @@ public HoldForMenuButton() AlwaysPresent = true; } - [BackgroundDependencyLoader(true)] - private void load(Player player, OsuConfigManager config) + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) { Children = new Drawable[] { @@ -64,7 +69,7 @@ private void load(Player player, OsuConfigManager config) Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft }, - button = new HoldButton(player?.Configuration.AllowRestart == false) + button = new HoldButton(isDangerousAction) { HoverGained = () => text.FadeIn(500, Easing.OutQuint), HoverLost = () => text.FadeOut(500, Easing.OutQuint), @@ -89,8 +94,8 @@ protected override void LoadComplete() button.HoldActivationDelay.BindValueChanged(v => { text.Text = v.NewValue > 0 - ? "hold for menu" - : "press for menu"; + ? UserInterfaceStrings.HoldForMenu + : UserInterfaceStrings.PressForMenu; }, true); touchActive = sessionStatics.GetBindable(Static.TouchInputActive); diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCountController.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCountController.cs index c00cb3487bb1..a979ed03fd63 100644 --- a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCountController.cs +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCountController.cs @@ -32,7 +32,7 @@ private void load(IBindable ruleset) { // Due to weirdness in judgements, some results have the same name and should be aggregated for display purposes. // There's only one case of this right now ("slider end"). - foreach (var group in ruleset.Value.CreateInstance().GetHitResults().GroupBy(r => r.displayName)) + foreach (var group in ruleset.Value.CreateInstance().GetHitResultsForDisplay().GroupBy(r => r.displayName)) { var judgementCount = new JudgementCount { diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index 635d140a4af1..4fe207a6f901 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -21,8 +21,6 @@ namespace osu.Game.Screens.Play.HUD { public partial class PlayerSettingsOverlay : ExpandingContainer { - public VisualSettings VisualSettings { get; private set; } - private const float padding = 10; public const float EXPANDED_WIDTH = player_settings_width + padding * 2; @@ -66,11 +64,7 @@ public PlayerSettingsOverlay() Direction = FillDirection.Vertical, Spacing = new Vector2(0, 20), Margin = new MarginPadding(padding), - Children = new PlayerSettingsGroup[] - { - VisualSettings = new VisualSettings { Expanded = { Value = false } }, - new AudioSettings { Expanded = { Value = false } } - } + Children = new PlayerSettingsGroup[] { new VisualSettings(), new AudioSettings() } }); // For future consideration, this icon should probably not exist. diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 806e59372901..9889ff460d48 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -73,6 +73,7 @@ protected override bool ShouldBeConsideredForInput(Drawable child) private readonly DrawableRuleset drawableRuleset; private readonly IReadOnlyList mods; + private readonly PlayerConfiguration configuration; /// /// Whether the elements that can optionally be hidden should be visible. @@ -113,12 +114,13 @@ protected override bool ShouldBeConsideredForInput(Drawable child) /// internal readonly Drawable PlayfieldSkinLayer; - public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList mods) + public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList mods, PlayerConfiguration configuration) { Container rightSettings; this.drawableRuleset = drawableRuleset; this.mods = mods; + this.configuration = configuration; RelativeSizeAxes = Axes.Both; @@ -173,7 +175,10 @@ public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList new HoldForMenuButton + protected HoldForMenuButton CreateHoldForMenuButton() => new HoldForMenuButton(!configuration.AllowRestart) { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, diff --git a/osu.Game/Screens/Play/HotkeyExitOverlay.cs b/osu.Game/Screens/Play/HotkeyExitOverlay.cs index bcd9bd7cd614..0908e044d027 100644 --- a/osu.Game/Screens/Play/HotkeyExitOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyExitOverlay.cs @@ -27,5 +27,14 @@ public void OnReleased(KeyBindingReleaseEvent e) AbortConfirm(); } + + protected override void Confirm() + { + base.Confirm(); + + // Not removing immediately can lead to delays due to async disposal. + // This is done here rather than in `Player` because it's simpler to handle. + RemoveAudioAdjustments(); + } } } diff --git a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs index 11d0b4f84f40..044aab7021ad 100644 --- a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs @@ -27,5 +27,14 @@ public void OnReleased(KeyBindingReleaseEvent e) AbortConfirm(); } + + protected override void Confirm() + { + base.Confirm(); + + // Not removing immediately can lead to delays due to async disposal. + // This is done here rather than in `Player` because it's simpler to handle. + RemoveAudioAdjustments(); + } } } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs b/osu.Game/Screens/Play/Leaderboards/BeatmapLeaderboardScope.cs similarity index 95% rename from osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs rename to osu.Game/Screens/Play/Leaderboards/BeatmapLeaderboardScope.cs index 497e4568810e..555a2a72c5e6 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs +++ b/osu.Game/Screens/Play/Leaderboards/BeatmapLeaderboardScope.cs @@ -4,7 +4,7 @@ using osu.Framework.Localisation; using osu.Game.Localisation; -namespace osu.Game.Screens.Select.Leaderboards +namespace osu.Game.Screens.Play.Leaderboards { public enum BeatmapLeaderboardScope { diff --git a/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/Leaderboards/GameplayLeaderboardScore.cs similarity index 98% rename from osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs rename to osu.Game/Screens/Play/Leaderboards/GameplayLeaderboardScore.cs index dfe95b8ccdd3..eb65306ae445 100644 --- a/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/Leaderboards/GameplayLeaderboardScore.cs @@ -9,10 +9,9 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; -using osu.Game.Screens.Play; using osu.Game.Users; -namespace osu.Game.Screens.Select.Leaderboards +namespace osu.Game.Screens.Play.Leaderboards { /// /// Represents a score shown on a gameplay leaderboard. diff --git a/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs b/osu.Game/Screens/Play/Leaderboards/IGameplayLeaderboardProvider.cs similarity index 94% rename from osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs rename to osu.Game/Screens/Play/Leaderboards/IGameplayLeaderboardProvider.cs index 9c4875477c15..350569f46f33 100644 --- a/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs +++ b/osu.Game/Screens/Play/Leaderboards/IGameplayLeaderboardProvider.cs @@ -3,7 +3,7 @@ using osu.Framework.Bindables; -namespace osu.Game.Screens.Select.Leaderboards +namespace osu.Game.Screens.Play.Leaderboards { /// /// Provides a leaderboard to show during gameplay. diff --git a/osu.Game/Screens/Select/Leaderboards/MultiSpectatorLeaderboardProvider.cs b/osu.Game/Screens/Play/Leaderboards/MultiSpectatorLeaderboardProvider.cs similarity index 95% rename from osu.Game/Screens/Select/Leaderboards/MultiSpectatorLeaderboardProvider.cs rename to osu.Game/Screens/Play/Leaderboards/MultiSpectatorLeaderboardProvider.cs index 19ae12a6caf1..a6f75e6ca14d 100644 --- a/osu.Game/Screens/Select/Leaderboards/MultiSpectatorLeaderboardProvider.cs +++ b/osu.Game/Screens/Play/Leaderboards/MultiSpectatorLeaderboardProvider.cs @@ -5,7 +5,7 @@ using osu.Framework.Timing; using osu.Game.Online.Multiplayer; -namespace osu.Game.Screens.Select.Leaderboards +namespace osu.Game.Screens.Play.Leaderboards { public partial class MultiSpectatorLeaderboardProvider : MultiplayerLeaderboardProvider { diff --git a/osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs b/osu.Game/Screens/Play/Leaderboards/MultiplayerLeaderboardProvider.cs similarity index 99% rename from osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs rename to osu.Game/Screens/Play/Leaderboards/MultiplayerLeaderboardProvider.cs index 08af8926df17..01bc56c1b57c 100644 --- a/osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs +++ b/osu.Game/Screens/Play/Leaderboards/MultiplayerLeaderboardProvider.cs @@ -24,7 +24,7 @@ using osu.Game.Rulesets.Scoring; using osuTK.Graphics; -namespace osu.Game.Screens.Select.Leaderboards +namespace osu.Game.Screens.Play.Leaderboards { [LongRunningLoad] public partial class MultiplayerLeaderboardProvider : CompositeComponent, IGameplayLeaderboardProvider diff --git a/osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs b/osu.Game/Screens/Play/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs similarity index 98% rename from osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs rename to osu.Game/Screens/Play/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs index ea0a2b68dcca..7efbfe23a822 100644 --- a/osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs +++ b/osu.Game/Screens/Play/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs @@ -9,9 +9,8 @@ using osu.Framework.Graphics; using osu.Game.Online.API; using osu.Game.Online.Rooms; -using osu.Game.Screens.Play; -namespace osu.Game.Screens.Select.Leaderboards +namespace osu.Game.Screens.Play.Leaderboards { [LongRunningLoad] public partial class PlaylistsGameplayLeaderboardProvider : Component, IGameplayLeaderboardProvider diff --git a/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs b/osu.Game/Screens/Play/Leaderboards/SoloGameplayLeaderboardProvider.cs similarity index 98% rename from osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs rename to osu.Game/Screens/Play/Leaderboards/SoloGameplayLeaderboardProvider.cs index 69e84ccaf882..62d1220c5022 100644 --- a/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs +++ b/osu.Game/Screens/Play/Leaderboards/SoloGameplayLeaderboardProvider.cs @@ -9,9 +9,8 @@ using osu.Framework.Graphics; using osu.Game.Online.Leaderboards; using osu.Game.Scoring; -using osu.Game.Screens.Play; -namespace osu.Game.Screens.Select.Leaderboards +namespace osu.Game.Screens.Play.Leaderboards { public partial class SoloGameplayLeaderboardProvider : Component, IGameplayLeaderboardProvider { @@ -58,6 +57,7 @@ protected override void LoadComplete() scores.AddRange(newScores); + sort(); Scheduler.AddDelayed(sort, 1000, true); } diff --git a/osu.Game/Screens/Play/LetterboxOverlay.cs b/osu.Game/Screens/Play/LetterboxOverlay.cs index 21fc6cf19cdd..f5c762ccf2cc 100644 --- a/osu.Game/Screens/Play/LetterboxOverlay.cs +++ b/osu.Game/Screens/Play/LetterboxOverlay.cs @@ -61,8 +61,6 @@ protected override void LoadComplete() private void updateDisplay(ValueChangedEvent period) { - FinishTransforms(true); - if (period.NewValue == null) return; @@ -71,7 +69,7 @@ private void updateDisplay(ValueChangedEvent period) using (BeginAbsoluteSequence(b.Start)) { fadeContainer.FadeInFromZero(BreakOverlay.BREAK_FADE_DURATION); - using (BeginDelayedSequence(b.Duration - BreakOverlay.BREAK_FADE_DURATION)) + using (BeginDelayedSequence(b.Duration)) fadeContainer.FadeOut(BreakOverlay.BREAK_FADE_DURATION); } } diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index 07ecb5a5fbbc..c9db6009d0d1 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -115,14 +115,15 @@ protected override void StartGameplayClock() /// /// Skip forward to the next valid skip point. /// - public void Skip() + /// true to skip as close to gameplay as possible, or false to skip only to the next valid skip point. + public void Skip(bool fullLength = false) { if (GameplayClock.CurrentTime > GameplayStartTime - MINIMUM_SKIP_TIME) return; double skipTarget = GameplayStartTime - MINIMUM_SKIP_TIME; - if (StartTime < -10000 && GameplayClock.CurrentTime < 0 && skipTarget > 6000) + if (!fullLength && StartTime < -10000 && GameplayClock.CurrentTime < 0 && skipTarget > 6000) // double skip exception for storyboards with very long intros skipTarget = 0; diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 22fb8a3463e3..97c0a0b7690e 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -154,7 +154,7 @@ public override bool RequiresPortraitOrientation private BreakTracker breakTracker; - private SkipOverlay skipIntroOverlay; + protected SkipOverlay SkipIntroOverlay { get; private set; } private SkipOverlay skipOutroOverlay; protected ScoreProcessor ScoreProcessor { get; private set; } @@ -311,16 +311,16 @@ private void load(OsuConfigManager config, OsuGameBase game, CancellationToken c { // underlay and gameplay should have access to the skinning sources. createUnderlayComponents(Beatmap.Value), - createGameplayComponents(Beatmap.Value) + createGameplayComponents() } }, FailOverlay = new FailOverlay { - SaveReplay = async () => await prepareAndImportScoreAsync(true).ConfigureAwait(false), + SaveReplay = Configuration.AllowUserInteraction ? async () => await prepareAndImportScoreAsync(true).ConfigureAwait(false) : null, OnRetry = Configuration.AllowUserInteraction ? () => Restart() : null, OnQuit = () => PerformExitWithConfirmation(), }, - new HotkeyExitOverlay + exitOverlay = new HotkeyExitOverlay { Action = () => { @@ -338,7 +338,7 @@ private void load(OsuConfigManager config, OsuGameBase game, CancellationToken c { rulesetSkinProvider.AddRange(new Drawable[] { - new HotkeyRetryOverlay + retryOverlay = new HotkeyRetryOverlay { Action = () => { @@ -426,6 +426,11 @@ private void load(OsuConfigManager config, OsuGameBase game, CancellationToken c IsBreakTime.BindValueChanged(onBreakTimeChanged, true); } + /// + /// Implement to add any components which should exist above gameplay but below the HUD. + /// + protected virtual Drawable CreateOverlayComponents() => Empty(); + protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); private Drawable createUnderlayComponents(WorkingBeatmap working) @@ -451,7 +456,7 @@ private Drawable createUnderlayComponents(WorkingBeatmap working) return container; } - private Drawable createGameplayComponents(IWorkingBeatmap working) => new ScalingContainer(ScalingMode.Gameplay) + private Drawable createGameplayComponents() => new ScalingContainer(ScalingMode.Gameplay) { Children = new Drawable[] { @@ -474,7 +479,8 @@ private Drawable createOverlayComponents() Children = new[] { DimmableStoryboard.OverlayLayerContainer.CreateProxy(), - HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods) + CreateOverlayComponents(), + HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration) { HoldToQuit = { @@ -500,10 +506,10 @@ private Drawable createOverlayComponents() }, // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), - skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) + SkipIntroOverlay = CreateSkipOverlay(DrawableRuleset.GameplayStartTime).With(o => { - RequestSkip = performUserRequestedSkip - }, + o.RequestSkip = RequestIntroSkip; + }), skipOutroOverlay = new SkipOverlay(GameplayState.Storyboard.LatestEventTime ?? 0) { RequestSkip = () => progressToResults(false), @@ -522,13 +528,15 @@ private Drawable createOverlayComponents() if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays) { - skipIntroOverlay.Expire(); + SkipIntroOverlay.Expire(); skipOutroOverlay.Expire(); } return container; } + protected virtual SkipOverlay CreateSkipOverlay(double startTime) => new SkipOverlay(startTime); + private void onBreakTimeChanged(ValueChangedEvent isBreakTime) { updateGameplayState(); @@ -701,13 +709,22 @@ protected bool PerformExit(bool skipTransition = false) return true; } - private void performUserRequestedSkip() + protected virtual void RequestIntroSkip() + { + PerformIntroSkip(); + } + + /// + /// Skip forward to the next valid skip point. + /// + /// true to skip as close to gameplay as possible, or false to skip only to the next valid skip point. + protected void PerformIntroSkip(bool fullLength = false) { // user requested skip // disable sample playback to stop currently playing samples and perform skip samplePlaybackDisabled.Value = true; - (GameplayClockContainer as MasterGameplayClockContainer)?.Skip(); + (GameplayClockContainer as MasterGameplayClockContainer)?.Skip(fullLength); // return samplePlaybackDisabled.Value to what is defined by the beatmap's current state updateSampleDisabledState(); @@ -1022,6 +1039,9 @@ private void onFailComplete() private double? lastPauseActionTime; + private HotkeyRetryOverlay retryOverlay; + private HotkeyExitOverlay exitOverlay; + protected bool PauseCooldownActive => PlayingState.Value == LocalUserPlayingState.Playing && lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + PauseCooldownDuration; @@ -1153,13 +1173,19 @@ protected virtual void StartGameplay() GameplayClockContainer.Reset(startClock: true); if (Configuration.AutomaticallySkipIntro) - skipIntroOverlay.SkipWhenReady(); + SkipIntroOverlay.SkipWhenReady(); } public override void OnSuspending(ScreenTransitionEvent e) { + Debug.Assert(!ValidForResume); + screenSuspension?.RemoveAndDisposeImmediately(); + // If these are not disposed, audio volume dimming can get stuck. + retryOverlay?.RemoveAndDisposeImmediately(); + exitOverlay?.RemoveAndDisposeImmediately(); + fadeOut(); base.OnSuspending(e); } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 57159afd22e7..d082ce6a5738 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -31,9 +31,11 @@ using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Volume; using osu.Game.Performance; +using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Play.PlayerSettings; -using osu.Game.Screens.Select.Leaderboards; using osu.Game.Skinning; using osu.Game.Users; using osu.Game.Utils; @@ -83,7 +85,7 @@ public partial class PlayerLoader : ScreenWithBeatmapBackground protected Task? DisposalTask { get; private set; } private FillFlowContainer disclaimers = null!; - private OsuScrollContainer settingsScroll = null!; + private GridContainer sideContent = null!; private Bindable showStoryboards = null!; @@ -135,6 +137,8 @@ protected bool BackgroundBrightnessReduction // or if a child of a focused overlay is focused, like settings' search textbox. && inputManager.FocusedDrawable?.FindClosestParent() == null; + private bool holdForMenuExitButton => !AllowUserExit; + private readonly Func createPlayer; /// @@ -225,27 +229,72 @@ private void load(SessionStatics sessionStatics, OsuConfigManager config) Padding = new MarginPadding(padding), Spacing = new Vector2(20), }, - settingsScroll = new OsuScrollContainer + sideContent = new GridContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Y, Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, - Padding = new MarginPadding { Vertical = padding }, - Masking = false, - Child = PlayerSettings = new FillFlowContainer + Padding = new MarginPadding + { + Bottom = ScreenFooter.HEIGHT + }, + RowDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + ], + Content = new[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Padding = new MarginPadding { Horizontal = padding }, - Children = new PlayerSettingsGroup[] + new Drawable[] { - VisualSettings = new VisualSettings(), - AudioSettings = new AudioSettings(), - new InputSettings() + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = PlayerSettings = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Padding = new MarginPadding + { + Horizontal = padding, + Vertical = padding, + }, + Children = new PlayerSettingsGroup[] + { + VisualSettings = new VisualSettings(), + AudioSettings = new AudioSettings(), + new InputSettings() + } + }, + } + }, + new Drawable[] + { + new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Alpha = holdForMenuExitButton ? 1 : 0, + AutoSizeAxes = Axes.Both, + Child = new HoldForMenuButton(true) + { + Margin = new MarginPadding + { + Top = 20, + Horizontal = padding, + Bottom = padding, + }, + Action = () => + { + if (this.IsCurrentScreen()) + this.Exit(); + } + } + } } - }, + } }, idleTracker = new IdleTracker(1500), sampleRestart = new SkinnableSound(new SampleInfo(@"Gameplay/restart", @"pause-retry-click")) @@ -305,7 +354,7 @@ public override void OnEntering(ScreenTransitionEvent e) // Start side content off-screen. disclaimers.MoveToX(-disclaimers.DrawWidth); - settingsScroll.MoveToX(settingsScroll.DrawWidth); + sideContent.MoveToX(sideContent.DrawWidth); content.ScaleTo(0.7f); @@ -551,8 +600,8 @@ private void contentIn(double delayBeforeSideDisplays = 0) using (BeginDelayedSequence(delayBeforeSideDisplays)) { - settingsScroll.FadeInFromZero(500, Easing.Out) - .MoveToX(0, 500, Easing.OutQuint); + sideContent.FadeInFromZero(500, Easing.Out) + .MoveToX(0, 500, Easing.OutQuint); disclaimers.FadeInFromZero(500, Easing.Out) .MoveToX(0, 500, Easing.OutQuint); @@ -592,8 +641,8 @@ protected virtual void ContentOut() disclaimers.FadeOut(CONTENT_OUT_DURATION, Easing.Out) .MoveToX(-disclaimers.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint); - settingsScroll.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint) - .MoveToX(settingsScroll.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint); + sideContent.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint) + .MoveToX(sideContent.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint); lowPassFilter?.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, CONTENT_OUT_DURATION); highPassFilter?.CutoffTo(0, CONTENT_OUT_DURATION); diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index e2337a4e0eae..634f1f85d248 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -330,7 +330,7 @@ private void scoreChanged(ValueChangedEvent score) if (offsetChanged) { - offsetText.AddText($"Beatmap offset was adjusted to {Current.Value.ToStandardFormattedString(1)} ms.", t => t.Font = OsuFont.Style.Caption1); + offsetText.AddText(BeatmapOffsetControlStrings.BeatmapOffsetWasAdjustedTo(Current.Value.ToStandardFormattedString(1)), t => t.Font = OsuFont.Style.Caption1); offsetText.NewParagraph(); } } diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 1c583609d938..e3c0361052b3 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -12,12 +12,16 @@ using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Ranking.Expanded; +using osu.Game.Skinning; using osu.Game.Users; namespace osu.Game.Screens.Play @@ -76,11 +80,7 @@ public ReplayPlayer(Func, Score> createScore, Playe /// Add a settings group to the HUD overlay. Intended to be used by rulesets to add replay-specific settings. /// /// The settings group to be shown. - public void AddSettings(PlayerSettingsGroup settings) => Schedule(() => - { - settings.Expanded.Value = false; - HUDOverlay.PlayerSettingsOverlay.Add(settings); - }); + public void AddSettings(PlayerSettingsGroup settings) => Schedule(() => HUDOverlay.PlayerSettingsOverlay.Add(settings)); [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -100,19 +100,44 @@ private void load(OsuConfigManager config) playbackSettings.UserPlaybackRate.BindTo(master.UserPlaybackRate); HUDOverlay.PlayerSettingsOverlay.AddAtStart(playbackSettings); - AddInternal(failIndicator = new ReplayFailIndicator(GameplayClockContainer) + + AddInternal(new RulesetSkinProvidingContainer(GameplayState.Ruleset, GameplayState.Beatmap, Beatmap.Value.Skin) { - GoToResults = () => + Child = failIndicator = new ReplayFailIndicator(GameplayClockContainer) { - if (!this.IsCurrentScreen()) - return; - - ValidForResume = false; - this.Push(new SoloResultsScreen(Score.ScoreInfo)); + GoToResults = () => + { + if (!this.IsCurrentScreen()) + return; + + ValidForResume = false; + this.Push(new SoloResultsScreen(Score.ScoreInfo)); + } } }); } + protected override Drawable CreateOverlayComponents() + { + OsuTextFlowContainer message = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Style.Body) { AutoSizeAxes = Axes.Both }; + message.AddText("Watching "); + message.AddText(Score.ScoreInfo.User.Username, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + message.AddText(" play "); + message.AddText(Beatmap.Value.BeatmapInfo.GetDisplayTitleRomanisable(), s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + message.AddText(" on "); + message.AddArbitraryDrawable(new PlayedOnText(Score.ScoreInfo.Date, false) + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }); + + return new ScrollingMessage(message) + { + Y = 100, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }; + } + protected override void PrepareReplay() { DrawableRuleset?.SetReplayScore(Score); diff --git a/osu.Game/Screens/Play/SaveFailedScoreButton.cs b/osu.Game/Screens/Play/SaveFailedScoreButton.cs index e5c9e115d133..af323281bdc3 100644 --- a/osu.Game/Screens/Play/SaveFailedScoreButton.cs +++ b/osu.Game/Screens/Play/SaveFailedScoreButton.cs @@ -96,8 +96,17 @@ private void load(OsuGame? game, Player? player) break; default: - button.TooltipText = @"save score"; - button.Enabled.Value = true; + if (importFailedScore != null) + { + button.TooltipText = @"save score"; + button.Enabled.Value = true; + } + else + { + button.TooltipText = @"replay unavailable"; + button.Enabled.Value = false; + } + break; } }, true); diff --git a/osu.Game/Screens/Play/ScrollingMessage.cs b/osu.Game/Screens/Play/ScrollingMessage.cs new file mode 100644 index 000000000000..9dcd303fe141 --- /dev/null +++ b/osu.Game/Screens/Play/ScrollingMessage.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Play +{ + public partial class ScrollingMessage : CompositeDrawable + { + private readonly Drawable messageContent; + + public ScrollingMessage(Drawable messageContent) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = this.messageContent = messageContent; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.FadeInFromZero(2000, Easing.OutQuint); + resetMessagePosition(); + } + + protected override void Update() + { + base.Update(); + + if (messageContent.X + messageContent.DrawWidth > 0) + messageContent.X -= (float)Clock.ElapsedFrameTime * 0.05f; + else + resetMessagePosition(); + } + + private void resetMessagePosition() + { + messageContent.X = DrawWidth + 10; + } + } +} diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index be8517d9a009..a7cea71142a0 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -9,16 +9,18 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; -using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; @@ -28,7 +30,7 @@ namespace osu.Game.Screens.Play { - public partial class SkipOverlay : BeatSyncedContainer, IKeyBindingHandler + public partial class SkipOverlay : Container, IKeyBindingHandler { /// /// The total number of successful skips performed by this overlay. @@ -38,20 +40,27 @@ public partial class SkipOverlay : BeatSyncedContainer, IKeyBindingHandler + /// Whether the gameplay clock is currently at the skippable period. + /// + private readonly BindableBool inSkipPeriod = new BindableBool(); + private bool skipQueued; [Resolved] private IGameplayClock gameplayClock { get; set; } - internal bool IsButtonVisible => fadeContainer.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible; + internal bool IsButtonVisible => FadingContent.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; /// @@ -77,22 +86,18 @@ private void load(OsuColour colours) InternalChild = buttonContainer = new ButtonContainer { RelativeSizeAxes = Axes.Both, - Child = fadeContainer = new FadeContainer + Child = FadingContent = new FadeContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - button = new Button - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - remainingTimeBox = new Circle + button = CreateButton(inSkipPeriod), + RemainingTimeBox = new Circle { Height = 5, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - Colour = colours.Yellow, + Colour = colours.Orange3, RelativeSizeAxes = Axes.X } } @@ -100,6 +105,17 @@ private void load(OsuColour colours) }; } + /// + /// Creates a skip button. + /// + /// Whether the gameplay clock is currently at the skippable period. + protected virtual OsuClickableContainer CreateButton(IBindable inSkipPeriod) => new Button + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Enabled = { BindTarget = inSkipPeriod }, + }; + private const double fade_time = 300; private double fadeOutBeginTime => startTime - MasterGameplayClockContainer.MINIMUM_SKIP_TIME; @@ -107,13 +123,13 @@ private void load(OsuColour colours) public override void Hide() { base.Hide(); - fadeContainer.Hide(); + FadingContent.Hide(); } public override void Show() { base.Show(); - fadeContainer.TriggerShow(); + FadingContent.TriggerShow(); } protected override void LoadComplete() @@ -136,7 +152,7 @@ protected override void LoadComplete() RequestSkip?.Invoke(); }; - fadeContainer.TriggerShow(); + FadingContent.TriggerShow(); } /// @@ -173,17 +189,16 @@ protected override void Update() double progress = Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); - remainingTimeBox.Width = (float)Interpolation.Lerp(remainingTimeBox.Width, progress, Math.Clamp(Time.Elapsed / 40, 0, 1)); + RemainingTimeBox.Width = (float)Interpolation.DampContinuously(RemainingTimeBox.Width, progress, 40, Math.Abs(Time.Elapsed)); - isClickable = progress > 0; - button.Enabled.Value = isClickable; - buttonContainer.State.Value = isClickable ? Visibility.Visible : Visibility.Hidden; + inSkipPeriod.Value = progress > 0; + buttonContainer.State.Value = inSkipPeriod.Value ? Visibility.Visible : Visibility.Hidden; } protected override bool OnMouseMove(MouseMoveEvent e) { - if (isClickable && !e.HasAnyButtonPressed) - fadeContainer.TriggerShow(); + if (inSkipPeriod.Value && !e.HasAnyButtonPressed) + FadingContent.TriggerShow(); return base.OnMouseMove(e); } @@ -210,18 +225,6 @@ public void OnReleased(KeyBindingReleaseEvent e) { } - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) - { - base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); - - if (fadeOutBeginTime <= gameplayClock.CurrentTime) - return; - - float progress = (float)(gameplayClock.CurrentTime - displayTime) / (float)(fadeOutBeginTime - displayTime); - float newWidth = 1 - Math.Clamp(progress, 0, 1); - remainingTimeBox.ResizeWidthTo(newWidth, timingPoint.BeatLength * 3.5, Easing.OutQuint); - } - public partial class FadeContainer : Container, IStateful { [CanBeNull] @@ -327,8 +330,8 @@ public Button() [BackgroundDependencyLoader] private void load(OsuColour colours, AudioManager audio) { - colourNormal = colours.Yellow; - colourHover = colours.YellowDark; + colourNormal = colours.Orange3; + colourHover = colours.Orange3.Lighten(0.2f); sampleConfirm = audio.Samples.Get(@"UI/submit-select"); @@ -355,6 +358,11 @@ private void load(OsuColour colours, AudioManager audio) RelativeSizeAxes = Axes.Both, Colour = colourNormal, }, + new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(colourNormal.Lighten(0.2f), colourNormal) + }, flow = new FillFlowContainer { Anchor = Anchor.TopCentre, diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs index 1e9222e40aa8..ee891dd816a3 100644 --- a/osu.Game/Screens/Play/SoloPlayer.cs +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -13,7 +13,7 @@ using osu.Game.Online.Rooms; using osu.Game.Online.Solo; using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Screens.Play { diff --git a/osu.Game/Screens/Play/SoloSpectatorPlayer.cs b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs index 16b1ff7ccc9c..c0950929ba9e 100644 --- a/osu.Game/Screens/Play/SoloSpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs @@ -6,7 +6,7 @@ using osu.Framework.Screens; using osu.Game.Online.Spectator; using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osu.Game.Users; namespace osu.Game.Screens.Play @@ -21,7 +21,12 @@ public partial class SoloSpectatorPlayer : SpectatorPlayer protected override UserActivity InitialActivity => new UserActivity.SpectatingUser(Score.ScoreInfo); public SoloSpectatorPlayer(Score score) - : base(score, new PlayerConfiguration { AllowUserInteraction = false, ShowLeaderboard = true }) + : base(score, new PlayerConfiguration + { + AllowUserInteraction = false, + ShowLeaderboard = true, + AllowRestart = false + }) { this.score = score; } diff --git a/osu.Game/Screens/Play/SoloSpectatorScreen.cs b/osu.Game/Screens/Play/SoloSpectatorScreen.cs index 75f8da707cc4..e54cde4b0a94 100644 --- a/osu.Game/Screens/Play/SoloSpectatorScreen.cs +++ b/osu.Game/Screens/Play/SoloSpectatorScreen.cs @@ -30,7 +30,6 @@ namespace osu.Game.Screens.Play { - [Cached(typeof(IPreviewTrackOwner))] public partial class SoloSpectatorScreen : SpectatorScreen, IPreviewTrackOwner { [Resolved] diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index 22c966e0afbb..6d008447bb68 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -7,7 +7,7 @@ using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; @@ -29,17 +29,23 @@ protected SpectatorPlayer(Score score, PlayerConfiguration? configuration = null this.score = score; } - [BackgroundDependencyLoader] - private void load() + protected override Drawable CreateOverlayComponents() { - AddInternal(new OsuSpriteText + // TODO: This should be customised for `MultiplayerSpectatorPlayer` to be static and only show the player name. + // Or maybe we should completely redesign this to show the user avatar and other things if that happens. + OsuTextFlowContainer message = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Style.Body) { AutoSizeAxes = Axes.Both }; + message.AddText("Watching "); + message.AddText(Score.ScoreInfo.User.Username, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + message.AddText(" play "); + message.AddText(Beatmap.Value.BeatmapInfo.GetDisplayTitleRomanisable(), s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + message.AddText(" live", s => s.Font = s.Font.With(weight: FontWeight.Bold)); + + return new ScrollingMessage(message) { - Text = $"Watching {score.ScoreInfo.User.Username} playing live!", - Font = OsuFont.Default.With(size: 30), Y = 100, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - }); + }; } protected override void LoadComplete() diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 445d219c7f55..0f11b01ddec7 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -1,19 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; -using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -237,7 +233,7 @@ private void load(RealmAccess realmAccess, BeatmapDifficultyCache beatmapDifficu }); if (score.Date != default) - AddInternal(new PlayedOnText(score.Date)); + AddInternal(new PlayedOnText(score.Date, true)); } protected override void LoadComplete() @@ -268,40 +264,6 @@ protected override void LoadComplete() }); } - public partial class PlayedOnText : OsuSpriteText - { - private readonly DateTimeOffset time; - private readonly Bindable prefer24HourTime = new Bindable(); - - public PlayedOnText(DateTimeOffset time) - { - this.time = time; - - Anchor = Anchor.BottomCentre; - Origin = Anchor.BottomCentre; - Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold); - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager configManager) - { - configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - prefer24HourTime.BindValueChanged(_ => updateDisplay(), true); - } - - private void updateDisplay() - { - Text = LocalisableString.Format("Played on {0}", - time.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt")); - } - } - internal partial class ClickableMetadata : OsuHoverContainer { [Resolved] diff --git a/osu.Game/Screens/Ranking/Expanded/PlayedOnText.cs b/osu.Game/Screens/Ranking/Expanded/PlayedOnText.cs new file mode 100644 index 000000000000..9ac453bfb76c --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/PlayedOnText.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Ranking.Expanded +{ + public partial class PlayedOnText : OsuSpriteText + { + private readonly DateTimeOffset time; + private readonly bool withPrefix; + private readonly Bindable prefer24HourTime = new Bindable(); + + public PlayedOnText(DateTimeOffset time, bool withPrefix) + { + this.time = time; + this.withPrefix = withPrefix; + + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold); + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + prefer24HourTime.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + var timeText = time.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt"); + + if (withPrefix) + Text = LocalisableString.Format("Played on {0}", timeText); + else + Text = timeText; + } + } +} diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 8d5e6c05c383..99f7f661cbe3 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -370,7 +370,7 @@ private Task addScores(ScoreInfo[] scores) } // allow a frame for scroll container to adjust its dimensions with the added scores before fetching again. - Schedule(() => tcs.SetResult()); + Schedule(tcs.SetResult); if (ScorePanelList.IsEmpty) { diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index 85da1afe7b67..72927ee6ebc5 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -33,7 +33,7 @@ public partial class ScorePanel : CompositeDrawable, IStateful /// /// Height of the panel when contracted. /// - private const float contracted_height = 385; + public const float CONTRACTED_HEIGHT = 385; /// /// Width of the panel when expanded. @@ -259,7 +259,7 @@ private void updateState() break; case PanelState.Contracted: - Size = new Vector2(CONTRACTED_WIDTH, contracted_height); + Size = new Vector2(CONTRACTED_WIDTH, CONTRACTED_HEIGHT); topLayerBackground.FadeColour(contracted_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); middleLayerBackground.FadeColour(contracted_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 5e0095611c7f..1ed6f70f23ad 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -12,7 +12,7 @@ using osu.Game.Online.API; using osu.Game.Online.Leaderboards; using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; namespace osu.Game.Screens.Ranking { diff --git a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs index fb7107cc88b3..08a67b8fdcde 100644 --- a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs +++ b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using osu.Framework.Localisation; using osu.Game.Rulesets.Scoring; +using osu.Game.Localisation; namespace osu.Game.Screens.Ranking.Statistics { @@ -17,11 +19,19 @@ public partial class AverageHitError : SimpleStatisticItem /// /// Sequence of s to calculate the unstable rate based on. public AverageHitError(IEnumerable hitEvents) - : base("Average Hit Error") + : base(RankingStatisticsStrings.AverageHitErrorTitle) { Value = hitEvents.CalculateAverageHitError(); } - protected override string DisplayValue(double? value) => value == null ? "(not available)" : $"{Math.Abs(value.Value):N2} ms {(value.Value < 0 ? "early" : "late")}"; + protected override LocalisableString DisplayValue(double? value) + { + return value == null ? RankingStatisticsStrings.NotAvailable : getEarlyLateText(value.Value); + + LocalisableString getEarlyLateText(double offset) => + offset < 0 + ? RankingStatisticsStrings.Early(Math.Abs(offset)) + : RankingStatisticsStrings.Late(Math.Abs(offset)); + } } } diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs index 280227baea78..f56b09cfc070 100644 --- a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -17,7 +20,7 @@ public abstract partial class SimpleStatisticItem : Container /// /// The text to display as the statistic's value. /// - protected string Value + protected LocalisableString Value { set => valueText.Text = value; } @@ -41,9 +44,9 @@ public float FontSize /// Creates a new simple statistic item. /// /// The name of the statistic. - protected SimpleStatisticItem(string name) + protected SimpleStatisticItem(LocalisableString name) { - Name = name; + Name = name.ToString(); RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -52,7 +55,7 @@ protected SimpleStatisticItem(string name) { nameText = new OsuSpriteText { - Text = Name, + Text = name, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(size: StatisticItem.FONT_SIZE) @@ -91,9 +94,15 @@ public partial class SimpleStatisticItem : SimpleStatisticItem /// Used to convert to a text representation. /// Defaults to using . /// - protected virtual string DisplayValue(TValue value) => value!.ToString() ?? string.Empty; + protected virtual LocalisableString DisplayValue(TValue value) + { + if (value is IFormattable formattable) + return formattable.ToLocalisableString(); + + return value!.ToString() ?? string.Empty; + } - public SimpleStatisticItem(string name) + public SimpleStatisticItem(LocalisableString name) : base(name) { } diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index 5c5c814c5b98..55e029f0410f 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -21,6 +21,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Placeholders; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics.User; using osuTK; @@ -258,6 +259,8 @@ protected virtual IEnumerable CreateStatisticItems(ScoreInfo newS preventTaggingReason = "Play the beatmap in its original ruleset to contribute to beatmap tags!"; else if (localUserScore.Rank < ScoreRank.C) preventTaggingReason = "Set a better score to contribute to beatmap tags!"; + else if (localUserScore.Mods.Any(m => (m.Type == ModType.Conversion) && !(m is ModClassic))) + preventTaggingReason = "Play this beatmap without conversion mods to contribute to beatmap tags!"; if (preventTaggingReason == null) { diff --git a/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs index c89e48e78df2..3cdc29e26807 100644 --- a/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs +++ b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs @@ -2,6 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Rulesets.Scoring; namespace osu.Game.Screens.Ranking.Statistics @@ -16,11 +19,11 @@ public partial class UnstableRate : SimpleStatisticItem /// /// Sequence of s to calculate the unstable rate based on. public UnstableRate(IReadOnlyList hitEvents) - : base("Unstable Rate") + : base(RankingStatisticsStrings.UnstableRateTitle) { Value = hitEvents.CalculateUnstableRate()?.Result; } - protected override string DisplayValue(double? value) => value?.ToString(@"N2") ?? "(not available)"; + protected override LocalisableString DisplayValue(double? value) => value?.ToLocalisableString(@"N2") ?? RankingStatisticsStrings.NotAvailable; } } diff --git a/osu.Game/Screens/Ranking/Statistics/User/RankedScoreChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/RankedScoreChangeRow.cs index 1cdf22bd75b3..08601ca912e4 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/RankedScoreChangeRow.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/RankedScoreChangeRow.cs @@ -3,6 +3,7 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Screens.Ranking.Statistics.User @@ -15,6 +16,7 @@ public RankedScoreChangeRow() } protected override LocalisableString Label => UsersStrings.ShowStatsRankedScore; + public override LocalisableString TooltipText => RankingStatisticsStrings.ClassicScoringAlwaysUsed; protected override LocalisableString FormatCurrentValue(long current) => current.ToLocalisableString(@"N0"); diff --git a/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs index e6a6530345f0..80f2fbc7a602 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs @@ -6,6 +6,7 @@ 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.Sprites; using osu.Framework.Localisation; @@ -17,7 +18,7 @@ namespace osu.Game.Screens.Ranking.Statistics.User { - public abstract partial class RankingChangeRow : CompositeDrawable + public abstract partial class RankingChangeRow : CompositeDrawable, IHasTooltip { public Bindable StatisticsUpdate { get; } = new Bindable(); @@ -153,6 +154,7 @@ private void onStatisticsUpdate(ValueChangedEvent default; protected abstract LocalisableString FormatCurrentValue(T current); protected abstract int CalculateDifference(T previous, T current, out LocalisableString formattedDifference); diff --git a/osu.Game/Screens/Ranking/Statistics/User/TotalScoreChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/TotalScoreChangeRow.cs index 346de18e14c3..9b04f33be576 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/TotalScoreChangeRow.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/TotalScoreChangeRow.cs @@ -3,6 +3,7 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Screens.Ranking.Statistics.User @@ -15,6 +16,7 @@ public TotalScoreChangeRow() } protected override LocalisableString Label => UsersStrings.ShowStatsTotalScore; + public override LocalisableString TooltipText => RankingStatisticsStrings.ClassicScoringAlwaysUsed; protected override LocalisableString FormatCurrentValue(long current) => current.ToLocalisableString(@"N0"); diff --git a/osu.Game/Screens/Ranking/UserTagControl.DrawableUserTag.cs b/osu.Game/Screens/Ranking/UserTagControl.DrawableUserTag.cs index 0f88515f598b..2a26ebc3c688 100644 --- a/osu.Game/Screens/Ranking/UserTagControl.DrawableUserTag.cs +++ b/osu.Game/Screens/Ranking/UserTagControl.DrawableUserTag.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Screens.Ranking { @@ -20,12 +21,6 @@ public partial class UserTagControl { public partial class DrawableUserTag : OsuAnimatedButton { - /// - /// Minimum count of votes required to display a tag on the beatmap's page. - /// Should match value specified web-side as https://github.com/ppy/osu-web/blob/cae2fdf03cfb8c30c8e332cfb142e03188ceffef/config/osu.php#L59. - /// - public const int MIN_VOTES_DISPLAY = 5; - public readonly UserTag UserTag; public Action? OnSelected { get; set; } @@ -66,7 +61,7 @@ public DrawableUserTag(UserTag userTag, bool showVoteCount = true) [BackgroundDependencyLoader] private void load() { - CornerRadius = 5; + CornerRadius = 10; Masking = true; EdgeEffect = new EdgeEffectParameters @@ -160,7 +155,7 @@ protected override void LoadComplete() { voteCount.BindValueChanged(_ => { - confirmed.Value = voteCount.Value >= MIN_VOTES_DISPLAY; + confirmed.Value = voteCount.Value >= APIBeatmap.MINIMUM_USER_TAG_VOTES_FOR_DISPLAY; }, true); voted.BindValueChanged(v => { diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 0d75ddb0f0e7..2cf8c7d2d041 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -7,1274 +7,1221 @@ using System.Diagnostics; using System.Linq; using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Caching; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Pooling; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Framework.Layout; +using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Database; -using osu.Game.Extensions; -using osu.Game.Graphics.Containers; -using osu.Game.Input.Bindings; -using osu.Game.Screens.Select.Carousel; -using osu.Game.Screens.Select.Filter; -using osuTK; -using osuTK.Input; +using osu.Game.Graphics; +using osu.Game.Graphics.Carousel; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using Realms; namespace osu.Game.Screens.Select { - public partial class BeatmapCarousel : CompositeDrawable, IKeyBindingHandler + [Cached] + public partial class BeatmapCarousel : Carousel { - /// - /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. - /// - public float BleedTop { get; set; } - - /// - /// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it. - /// - public float BleedBottom { get; set; } + public Action? RequestPresentBeatmap { private get; init; } /// - /// Triggered when finish loading, or are subsequently changed. + /// From the provided beatmaps, select the most appropriate one for the user's skill. /// - public Action? BeatmapSetsChanged; + public required Action> RequestRecommendedSelection { private get; init; } /// - /// Triggered after filter conditions have finished being applied to the model hierarchy. + /// Selection requested for the provided beatmap. /// - public Action? FilterApplied; - - /// - /// The currently selected beatmap. - /// - public BeatmapInfo? SelectedBeatmapInfo => selectedBeatmap?.BeatmapInfo; - - private CarouselBeatmap? selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected); + public required Action RequestSelection { private get; init; } - /// - /// The total count of non-filtered beatmaps displayed. - /// - public int CountDisplayed => beatmapSets.Where(s => !s.Filtered.Value).Sum(s => s.TotalItemsNotFiltered); + public const float SPACING = 3f; - /// - /// The currently selected beatmap set. - /// - public BeatmapSetInfo? SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet; + private IBindableList detachedBeatmaps = null!; - /// - /// A function to optionally decide on a recommended difficulty from a beatmap set. - /// - public Func, BeatmapInfo?>? GetRecommendedBeatmap; + private readonly LoadingLayer loading; - private CarouselBeatmapSet? selectedBeatmapSet; + private readonly BeatmapCarouselFilterGrouping grouping; /// - /// Raised when the is changed. + /// Total number of beatmap difficulties displayed with the filter. /// - public Action? SelectionChanged; - - public override bool HandleNonPositionalInput => AllowSelection; - public override bool HandlePositionalInput => AllowSelection; - - public override bool PropagatePositionalInputSubTree => AllowSelection; - public override bool PropagateNonPositionalInputSubTree => AllowSelection; - - private (int first, int last) displayedRange; - - /// - /// Extend the range to retain already loaded pooled drawables. - /// - private const float distance_offscreen_before_unload = 2048; - - /// - /// Extend the range to update positions / retrieve pooled drawables outside of visible range. - /// - private const float distance_offscreen_to_preload = 768; - - /// - /// Whether carousel items have completed asynchronously loaded. - /// - public bool BeatmapSetsLoaded { get; private set; } - - [Cached] - protected readonly CarouselScrollContainer Scroll; - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - private IBindableList? detachedBeatmapSets; - - private readonly NoResultsPlaceholder noResultsPlaceholder; - - private IEnumerable beatmapSets => root.Items.OfType(); - - internal IEnumerable BeatmapSets => beatmapSets.Select(g => g.BeatmapSet); + public int MatchedBeatmapsCount => Filters.Last().BeatmapItemsCount; - private void loadNewRoot() + protected override float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom) { - beatmapsSplitOut = activeCriteria.Sort == SortMode.Difficulty; + // Group panels do not overlap with any other panel but should overlap with themselves. + if ((top.Model is GroupDefinition) ^ (bottom.Model is GroupDefinition)) + return SPACING * 2; - // Ensure no changes are made to the list while we are initialising items. - // We'll catch up on changes via subscriptions anyway. - BeatmapSetInfo[] loadableSets = detachedBeatmapSets!.ToArray(); - - if (selectedBeatmapSet != null && !loadableSets.Contains(selectedBeatmapSet.BeatmapSet, EqualityComparer.Default)) - selectedBeatmapSet = null; - - var selectedBeatmapBefore = selectedBeatmap?.BeatmapInfo; - - CarouselRoot newRoot = new CarouselRoot(this); - - if (beatmapsSplitOut) + if (grouping.BeatmapSetsGroupedTogether) { - var carouselBeatmapSets = loadableSets.SelectMany(s => s.Beatmaps).Select(b => - { - return createCarouselSet(new BeatmapSetInfo(new[] { b }) - { - ID = b.BeatmapSet!.ID, - OnlineID = b.BeatmapSet!.OnlineID, - Status = b.BeatmapSet!.Status, - }); - }).OfType(); + // Give some space around the expanded beatmap set, at the top.. + if (bottom.Model is GroupedBeatmapSet && bottom.IsExpanded) + return SPACING * 2; - newRoot.AddItems(carouselBeatmapSets); - } - else - { - var carouselBeatmapSets = loadableSets.Select(createCarouselSet).OfType(); + // ..and the bottom. + if (top.Model is GroupedBeatmap && bottom.Model is GroupedBeatmapSet) + return SPACING * 2; - newRoot.AddItems(carouselBeatmapSets); + // Beatmap difficulty panels do not overlap with themselves or any other panel. + if (top.Model is GroupedBeatmap || bottom.Model is GroupedBeatmap) + return SPACING; } - - root = newRoot; - root.Filter(activeCriteria); - - Scroll.Clear(false); - itemsCache.Invalidate(); - ScrollToSelected(); - - // Restore selection - if (selectedBeatmapBefore != null && newRoot.BeatmapSetsByID.TryGetValue(selectedBeatmapBefore.BeatmapSet!.ID, out var newSelectionCandidates)) + else { - CarouselBeatmap? found = newSelectionCandidates.SelectMany(s => s.Beatmaps).SingleOrDefault(b => b.BeatmapInfo.ID == selectedBeatmapBefore.ID); - - if (found != null) - found.State.Value = CarouselItemState.Selected; + if (CurrentSelection != null && (top == CurrentSelectionItem || bottom == CurrentSelectionItem)) + return SPACING * 2; } - Schedule(() => - { - invalidateAfterChange(); - BeatmapSetsLoaded = true; - }); + return -SPACING; } - private readonly List visibleItems = new List(); - - private readonly Cached itemsCache = new Cached(); - private PendingScrollOperation pendingScrollOperation = PendingScrollOperation.None; - - public Bindable RandomAlgorithm = new Bindable(); - private readonly List previouslyVisitedRandomSets = new List(); - private readonly List randomSelectedBeatmaps = new List(); - - private CarouselRoot root; - - private readonly DrawablePool setPool = new DrawablePool(100); - - private Sample? spinSample; - private Sample? randomSelectSample; + public BeatmapCarousel() + { + DebounceDelay = 100; + DistanceOffscreenToPreload = 100; - private int visibleSetsCount; + // Account for the osu! logo being in the way. + Scroll.ScrollbarPaddingBottom = 70; - public BeatmapCarousel(FilterCriteria initialCriteria) - { - root = new CarouselRoot(this); - InternalChild = new Container + Filters = new ICarouselFilter[] { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + new BeatmapCarouselFilterMatching(() => Criteria!), + new BeatmapCarouselFilterSorting(() => Criteria!), + grouping = new BeatmapCarouselFilterGrouping { - setPool, - Scroll = new CarouselScrollContainer - { - RelativeSizeAxes = Axes.Both, - }, - noResultsPlaceholder = new NoResultsPlaceholder() + GetCriteria = () => Criteria!, + GetCollections = GetAllCollections, + GetLocalUserTopRanks = GetBeatmapInfoGuidToTopRankMapping, + GetFavouriteBeatmapSets = GetFavouriteBeatmapSets, } }; - activeCriteria = initialCriteria; + AddInternal(loading = new LoadingLayer()); } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, AudioManager audio, BeatmapStore beatmaps, CancellationToken? cancellationToken) + private void load(BeatmapStore beatmapStore, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken) { - spinSample = audio.Samples.Get("SongSelect/random-spin"); - randomSelectSample = audio.Samples.Get(@"SongSelect/select-random"); + setupPools(); + detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); + loadSamples(audio); - config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm); + config.BindWith(OsuSetting.RandomSelectAlgorithm, randomAlgorithm); + } - detachedBeatmapSets = beatmaps.GetBeatmapSets(cancellationToken); - detachedBeatmapSets.BindCollectionChanged(beatmapSetsChanged); - loadNewRoot(); + protected override void LoadComplete() + { + base.LoadComplete(); + detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true); } - private readonly HashSet setsRequiringUpdate = new HashSet(); - private readonly HashSet setsRequiringRemoval = new HashSet(); + #region Beatmap source hookup - private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) + private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) => Schedule(() => { - IEnumerable? oldBeatmapSets = changed.OldItems?.Cast(); - HashSet oldBeatmapSetIDs = oldBeatmapSets?.Select(s => s.ID).ToHashSet() ?? []; + // This callback is scheduled to ensure there's no added overhead during gameplay. + // If this ever becomes an issue, it's important to note that the actual carousel filtering is already + // implemented in a way it will only run when at song select. + // + // The overhead we are avoiding here is that of this method directly – things like Items.IndexOf calls + // that can be slow for very large beatmap libraries. There are definitely ways to optimise this further. - IEnumerable? newBeatmapSets = changed.NewItems?.Cast(); - HashSet newBeatmapSetIDs = newBeatmapSets?.Select(s => s.ID).ToHashSet() ?? []; + // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider. + // right now we are managing this locally which is a bit of added overhead. + IEnumerable? newItems = changed.NewItems?.Cast(); + IEnumerable? oldItems = changed.OldItems?.Cast(); switch (changed.Action) { case NotifyCollectionChangedAction.Add: - setsRequiringRemoval.RemoveWhere(s => newBeatmapSetIDs.Contains(s.ID)); - setsRequiringUpdate.AddRange(newBeatmapSets!); - break; + if (!newItems!.Any()) + return; - case NotifyCollectionChangedAction.Remove: - setsRequiringUpdate.RemoveWhere(s => oldBeatmapSetIDs.Contains(s.ID)); - setsRequiringRemoval.AddRange(oldBeatmapSets!); + Items.AddRange(newItems!.SelectMany(s => s.Beatmaps)); break; - case NotifyCollectionChangedAction.Replace: - setsRequiringUpdate.RemoveWhere(s => oldBeatmapSetIDs.Contains(s.ID)); - setsRequiringRemoval.AddRange(oldBeatmapSets!); - - setsRequiringRemoval.RemoveWhere(s => newBeatmapSetIDs.Contains(s.ID)); - setsRequiringUpdate.AddRange(newBeatmapSets!); - break; - - case NotifyCollectionChangedAction.Move: - setsRequiringUpdate.AddRange(newBeatmapSets!); - break; + case NotifyCollectionChangedAction.Remove: + bool selectedSetDeleted = false; - case NotifyCollectionChangedAction.Reset: - setsRequiringRemoval.Clear(); - setsRequiringUpdate.Clear(); - loadNewRoot(); - break; - } + foreach (var set in oldItems!) + { + foreach (var beatmap in set.Beatmaps) + { + Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi)); + selectedSetDeleted |= CheckModelEquality((CurrentSelection as GroupedBeatmap)?.Beatmap, beatmap); + } + } - Scheduler.AddOnce(processBeatmapChanges); - } + // After removing all items in this batch, we want to make an immediate reselection + // based on adjacency to the previous selection if it was deleted. + // + // This needs to be done immediately to avoid song select making a random selection. + // This needs to be done in this class because we need to know final display order. + // This needs to be done with attention to detail of which beatmaps have not been deleted. + if (selectedSetDeleted && CurrentSelectionIndex != null) + { + var items = GetCarouselItems()!; + if (items.Count == 0) + break; - // All local operations must be scheduled. - // - // If we don't schedule, beatmaps getting changed while song select is suspended (ie. last played being updated) - // will cause unexpected sounds and operations to occur in the background. - private void processBeatmapChanges() - { - try - { - // To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions. - // When an update occurs, the previous beatmap set is either soft or hard deleted. - // Check if the current selection was potentially deleted by re-querying its validity. - bool selectedSetMarkedDeleted = SelectedBeatmapSet != null && fetchFromID(SelectedBeatmapSet.ID)?.DeletePending != false; + bool success = false; - foreach (var set in setsRequiringRemoval) removeBeatmapSet(set.ID); + // Try selecting forwards first + for (int i = CurrentSelectionIndex.Value + 1; i < items.Count; i++) + { + if (attemptSelection(items[i])) + { + success = true; + break; + } + } - foreach (var set in setsRequiringUpdate) updateBeatmapSet(set); + if (success) + break; - if (setsRequiringRemoval.Count > 0 && SelectedBeatmapInfo != null) - { - // If SelectedBeatmapInfo is non-null, the set should also be non-null. - Debug.Assert(SelectedBeatmapSet != null); + // Then try backwards (we might be at the end of available items). + for (int i = Math.Min(items.Count - 1, CurrentSelectionIndex.Value); i >= 0; i--) + { + if (attemptSelection(items[i])) + break; + } - if (selectedSetMarkedDeleted && setsRequiringUpdate.Any()) - { - // If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices. - // This relies on the full update operation being in a single transaction, so please don't change that. - foreach (var set in setsRequiringUpdate) + bool attemptSelection(CarouselItem item) { - foreach (var beatmapInfo in set.Beatmaps) + if (CheckValidForSetSelection(item)) { - if (!((IBeatmapMetadataInfo)beatmapInfo.Metadata).Equals(SelectedBeatmapInfo.Metadata)) continue; + if (item.Model is GroupedBeatmap groupedBeatmap) + { + // check the new selection wasn't deleted above + if (!Items.Contains(groupedBeatmap.Beatmap)) + return false; + + RequestSelection(groupedBeatmap); + return true; + } - // Best effort matching. We can't use ID because in the update flow a new version will get its own GUID. - if (beatmapInfo.DifficultyName == SelectedBeatmapInfo.DifficultyName) + if (item.Model is GroupedBeatmapSet groupedSet) { - SelectBeatmap(beatmapInfo); - return; + if (oldItems.Contains(groupedSet.BeatmapSet)) + return false; + + selectRecommendedDifficultyForBeatmapSet(groupedSet); + return true; } } - } - // If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed. - // Let's attempt to follow set-level selection anyway. - SelectBeatmap(setsRequiringUpdate.First().Beatmaps.First()); + return false; + } } - } - } - finally - { - BeatmapSetsLoaded = true; - invalidateAfterChange(); - } - setsRequiringRemoval.Clear(); - setsRequiringUpdate.Clear(); + break; - BeatmapSetInfo? fetchFromID(Guid id) => realm.Realm.Find(id); - } + case NotifyCollectionChangedAction.Move: + // We can ignore move operations as we are applying our own sort in all cases. + break; - public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => - { - removeBeatmapSet(beatmapSet.ID); - invalidateAfterChange(); - }); + case NotifyCollectionChangedAction.Replace: + var oldSetBeatmaps = oldItems!.Single().Beatmaps; + var newSetBeatmaps = newItems!.Single().Beatmaps.ToList(); + + // Handling replace operations is a touch manual, as we need to locally diff the beatmaps of each version of the beatmap set. + // Matching is done based on online IDs, then difficulty names as these are the most stable thing between updates (which are usually triggered + // by users editing the beatmap or by difficulty/metadata recomputation). + // + // In the case of difficulty reprocessing, this will trigger multiple times per beatmap as it's always triggering a set update. + // We may want to look to improve this in the future either here or at the source (only trigger an update after all difficulties + // have been processed) if it becomes an issue for animation or performance reasons. + foreach (var beatmap in oldSetBeatmaps) + { + int previousIndex = Items.IndexOf(beatmap); + Debug.Assert(previousIndex >= 0); - private void removeBeatmapSet(Guid beatmapSetID) - { - if (!root.BeatmapSetsByID.TryGetValue(beatmapSetID, out var existingSets)) - return; + // we're intentionally being lenient with there being two difficulties with equal online ID or difficulty name. + // this can be the case when the user modifies the beatmap using the editor's "external edit" feature. + BeatmapInfo? matchingNewBeatmap = + newSetBeatmaps.FirstOrDefault(b => b.OnlineID > 0 && b.OnlineID == beatmap.OnlineID) ?? + newSetBeatmaps.FirstOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset)); - foreach (var set in existingSets) - { - foreach (var beatmap in set.Beatmaps) - randomSelectedBeatmaps.Remove(beatmap); - previouslyVisitedRandomSets.Remove(set); + // The matching beatmap may have been deleted or invalidated in some way since this event was fired. + // Let's make sure we have the most up-to-date realm state. + if (matchingNewBeatmap?.ID is Guid matchingID) + matchingNewBeatmap = realm.Run(r => r.FindWithRefresh(matchingID)?.Detach()); - root.RemoveItem(set); - } - } + if (matchingNewBeatmap != null) + { + // TODO: should this exist in song select instead of here? + // we need to ensure the global beatmap is also updated alongside changes. + if (CurrentBeatmap != null && beatmap.Equals(CurrentBeatmap)) + // we don't know in which group the matching new beatmap is, but that's fine - we can keep the previous one for now. + // we are about to modify `Items`, which - if required - will trigger a re-filter, + // which will pick a correct group - if one is present - via `HandleFilterCompleted()`. + RequestSelection(new GroupedBeatmap(CurrentGroupedBeatmap?.Group, matchingNewBeatmap)); + + Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]); + newSetBeatmaps.Remove(matchingNewBeatmap); + } + else + { + Items.RemoveAt(previousIndex); + } + } - public void UpdateBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => - { - updateBeatmapSet(beatmapSet); - invalidateAfterChange(); + // Add any items which weren't found in the previous pass (difficulty names didn't match). + foreach (var beatmap in newSetBeatmaps) + Items.Add(beatmap); + + break; + + case NotifyCollectionChangedAction.Reset: + Items.Clear(); + break; + } }); - private void updateBeatmapSet(BeatmapSetInfo beatmapSet) - { - var newSets = new List(); + #endregion - if (beatmapsSplitOut) - { - foreach (var beatmap in beatmapSet.Beatmaps) - { - var newSet = createCarouselSet(new BeatmapSetInfo(new[] { beatmap }) - { - ID = beatmapSet.ID, - OnlineID = beatmapSet.OnlineID, - Status = beatmapSet.Status, - }); + #region Selection handling - if (newSet != null) - newSets.Add(newSet); - } - } - else - { - var newSet = createCarouselSet(beatmapSet); + protected GroupDefinition? ExpandedGroup { get; private set; } - if (newSet != null) - newSets.Add(newSet); - } + protected GroupedBeatmapSet? ExpandedBeatmapSet { get; private set; } - var removedSets = root.ReplaceItem(beatmapSet, newSets); + protected override bool ShouldActivateOnKeyboardSelection(CarouselItem item) => + grouping.BeatmapSetsGroupedTogether && item.Model is GroupedBeatmap; - // If we don't remove these here, it may remain in a hidden state until scrolled off screen. - // Doesn't really affect anything during actual user interaction, but makes testing annoying. - foreach (var removedSet in removedSets) - { - var removedDrawable = Scroll.FirstOrDefault(c => c.Item == removedSet); - if (removedDrawable != null) - expirePanelImmediately(removedDrawable); - } + /// + /// The currently selected . + /// + /// + /// The selection is never reset due to not existing. It can be set to anything. + /// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches. + /// + public GroupedBeatmap? CurrentGroupedBeatmap + { + get => CurrentSelection as GroupedBeatmap; + set => CurrentSelection = value; } /// - /// Selects a given beatmap on the carousel. + /// The currently selected . /// - /// The beatmap to select. - /// Whether to select the beatmap even if it is filtered (i.e., not visible on carousel). - /// True if a selection was made, False if it wasn't. - public bool SelectBeatmap(BeatmapInfo? beatmapInfo, bool bypassFilters = true) + /// + /// This is a property mostly dedicated to external consumers who only care about showing some particular copy of a beatmap + /// (there could be multiple panels for one beatmap due to grouping). + /// Through this property, the carousel basically figures out what group to use internally. + /// + public BeatmapInfo? CurrentBeatmap { - // ensure that any pending events from BeatmapManager have been run before attempting a selection. - Scheduler.Update(); - - if (beatmapInfo?.Hidden != false) - return false; - - foreach (CarouselBeatmapSet set in beatmapSets) + get => CurrentGroupedBeatmap?.Beatmap; + set { - if (!bypassFilters && set.Filtered.Value) - continue; - - var item = set.Beatmaps.FirstOrDefault(p => p.BeatmapInfo.Equals(beatmapInfo)); - - if (item == null) - // The beatmap that needs to be selected doesn't exist in this set - continue; - - if (!bypassFilters && item.Filtered.Value) - return false; - - select(item); - - // if we got here and the set is filtered, it means we were bypassing filters. - // in this case, reapplying the filter is necessary to ensure the panel is in the correct place - // (since it is forcefully being included in the carousel). - if (set.Filtered.Value) + if (value == null) { - Debug.Assert(bypassFilters); - - applyActiveCriteria(false); + CurrentGroupedBeatmap = null; + return; } - return true; - } + if (CurrentGroupedBeatmap != null && value.Equals(CurrentGroupedBeatmap.Beatmap)) + return; - return false; + // it is not universally guaranteed that the carousel items will be materialised at the time this is set. + // therefore, in cases where it is known that they will not be, default to a null group. + // even if grouping is active, this will be rectified to a correct group on the next invocation of `HandleFilterCompleted()`. + CurrentGroupedBeatmap = IsLoaded && !IsFiltering + ? GetCarouselItems()?.Select(item => item.Model).OfType().FirstOrDefault(gb => gb.Beatmap.Equals(value)) + : new GroupedBeatmap(null, value); + } } - /// - /// Increment selection in the carousel in a chosen direction. - /// - /// The direction to increment. Negative is backwards. - /// Whether to skip individual difficulties and only increment over full groups. - public void SelectNext(int direction = 1, bool skipDifficulties = true) + protected override void HandleItemActivated(CarouselItem item) { - if (beatmapSets.All(s => s.Filtered.Value)) - return; + try + { + switch (item.Model) + { + case GroupDefinition group: + // Special case – collapsing an open group. + if (ExpandedGroup == group) + { + setExpansionStateOfGroup(ExpandedGroup, false); + ExpandedGroup = null; + return; + } - if (skipDifficulties) - selectNextSet(direction, true); - else - selectNextDifficulty(direction); - } + setExpandedGroup(group); - private void selectNextSet(int direction, bool skipDifficulties) - { - if (selectedBeatmap == null || selectedBeatmapSet == null) - return; + // If the active selection is within this group, it should get keyboard focus immediately. + if (CurrentSelectionItem?.IsVisible == true && CurrentSelection is GroupedBeatmap gb) + RequestSelection(gb); - var unfilteredSets = beatmapSets.Where(s => !s.Filtered.Value).ToList(); + return; - var nextSet = unfilteredSets[(unfilteredSets.IndexOf(selectedBeatmapSet) + direction + unfilteredSets.Count) % unfilteredSets.Count]; + case GroupedBeatmapSet groupedSet: + selectRecommendedDifficultyForBeatmapSet(groupedSet); + return; - if (skipDifficulties) - select(nextSet); - else - select(direction > 0 ? nextSet.Beatmaps.First(b => !b.Filtered.Value) : nextSet.Beatmaps.Last(b => !b.Filtered.Value)); + case GroupedBeatmap groupedBeatmap: + if (CurrentSelection != null && CheckModelEquality(CurrentSelection, groupedBeatmap)) + { + RequestPresentBeatmap?.Invoke(groupedBeatmap.Beatmap); + return; + } + + RequestSelection(groupedBeatmap); + return; + } + } + finally + { + playActivationSound(item); + } } - private void selectNextDifficulty(int direction) + protected override void HandleItemSelected(object? model) { - if (selectedBeatmap == null || selectedBeatmapSet == null) - return; + base.HandleItemSelected(model); - var unfilteredDifficulties = selectedBeatmapSet.Items.Where(s => !s.Filtered.Value).ToList(); + switch (model) + { + case GroupedBeatmapSet: + case GroupDefinition: + throw new InvalidOperationException("Groups should never become selected"); - int index = unfilteredDifficulties.IndexOf(selectedBeatmap); + case GroupedBeatmap groupedBeatmap: + setExpandedGroup(groupedBeatmap.Group); - if (index + direction < 0 || index + direction >= unfilteredDifficulties.Count) - selectNextSet(direction, false); - else - select(unfilteredDifficulties[index + direction]); + setExpandedSet(new GroupedBeatmapSet(groupedBeatmap.Group, groupedBeatmap.Beatmap.BeatmapSet!)); + break; + } } - /// - /// Select the next beatmap in the random sequence. - /// - /// True if a selection could be made, else False. - public bool SelectNextRandom() + protected override bool HandleItemsChanged(NotifyCollectionChangedEventArgs args) { - if (!AllowSelection) - return false; - - var visibleSets = beatmapSets.Where(s => !s.Filtered.Value).ToList(); - - visibleSetsCount = visibleSets.Count; - - if (!visibleSets.Any()) - return false; - - if (selectedBeatmap != null && selectedBeatmapSet != null) + switch (args.Action) { - randomSelectedBeatmaps.Add(selectedBeatmap); - - // when performing a random, we want to add the current set to the previously visited list - // else the user may be "randomised" to the existing selection. - if (previouslyVisitedRandomSets.LastOrDefault() != selectedBeatmapSet) - previouslyVisitedRandomSets.Add(selectedBeatmapSet); - } + case NotifyCollectionChangedAction.Add: + case NotifyCollectionChangedAction.Remove: + case NotifyCollectionChangedAction.Move: + case NotifyCollectionChangedAction.Reset: + return true; - CarouselBeatmapSet set; + case NotifyCollectionChangedAction.Replace: + var oldBeatmaps = args.OldItems!.OfType().ToList(); + var newBeatmaps = args.NewItems!.OfType().ToList(); - if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) - { - var notYetVisitedSets = visibleSets.Except(previouslyVisitedRandomSets).ToList(); + for (int i = 0; i < oldBeatmaps.Count; i++) + { + var oldBeatmap = oldBeatmaps[i]; + var newBeatmap = newBeatmaps[i]; + + // Ignore changes which don't concern us. + // + // Here are some examples of things that can go wrong: + // - Background difficulty calculation runs and causes a realm update. + // We use `BeatmapDifficultyCache` and don't want to know about these. + // - Background user tag population runs and causes a realm update. + // We don't display user tags so want to ignore this. + bool equalForDisplayPurposes = + // covers import-as-update flows, such as updating the beatmap with the latest online versions, or external editing inside editor + oldBeatmap.ID == newBeatmap.ID && + // covers metadata changes + oldBeatmap.Hash == newBeatmap.Hash && + // sanity check + oldBeatmap.OnlineID == newBeatmap.OnlineID && + // displayed on panel + oldBeatmap.Status == newBeatmap.Status && + // displayed on panel + oldBeatmap.DifficultyName == newBeatmap.DifficultyName && + // hidden changed, needs re-filter + oldBeatmap.Hidden == newBeatmap.Hidden && + // might be used for grouping, returning from gameplay + oldBeatmap.LastPlayed == newBeatmap.LastPlayed; + + if (equalForDisplayPurposes) + return false; + } - if (!notYetVisitedSets.Any()) - { - previouslyVisitedRandomSets.RemoveAll(s => visibleSets.Contains(s)); - notYetVisitedSets = visibleSets; - } + return true; - set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count)); - previouslyVisitedRandomSets.Add(set); + default: + throw new ArgumentOutOfRangeException(); } - else - set = visibleSets.ElementAt(RNG.Next(visibleSets.Count)); + } - if (selectedBeatmapSet != null) - playSpinSample(distanceBetween(set, selectedBeatmapSet)); + protected override void FindCarouselItemsForSelection(ref Selection keyboardSelection, ref Selection selection, IList items) + { + if (keyboardSelection.Model != null && grouping.ItemMap.TryGetValue(keyboardSelection.Model, out var keyboardSelectionItem)) + keyboardSelection = keyboardSelection with { CarouselItem = keyboardSelectionItem.item, Index = keyboardSelectionItem.index }; - select(set); - return true; + if (selection.Model != null && grouping.ItemMap.TryGetValue(selection.Model, out var selectionItem)) + selection = selection with { CarouselItem = selectionItem.item, Index = selectionItem.index }; } - public void SelectPreviousRandom() + protected override void HandleFilterCompleted() { - while (randomSelectedBeatmaps.Any()) - { - var beatmap = randomSelectedBeatmaps[^1]; - randomSelectedBeatmaps.RemoveAt(randomSelectedBeatmaps.Count - 1); - - if (!beatmap.Filtered.Value && beatmap.BeatmapInfo.BeatmapSet?.DeletePending != true) - { - if (selectedBeatmapSet != null) - { - if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) - previouslyVisitedRandomSets.Remove(selectedBeatmapSet); + base.HandleFilterCompleted(); - playSpinSample(distanceBetween(beatmap, selectedBeatmapSet)); - } + attemptSelectSingleFilteredResult(); - select(beatmap); - break; + if (CurrentSelection is GroupedBeatmap selection) + { + // Check whether the selection-group mapping is still valid post-filter. + if (!grouping.ItemMap.ContainsKey(selection)) + { + // If the group no longer exists (or the item no longer exists in the previous group), grab an arbitrary other instance of the beatmap under the first group encountered. + var newSelection = GetCarouselItems()? + .Select(i => i.Model) + .OfType() + .FirstOrDefault(gb => CheckModelEquality(gb.Beatmap, selection.Beatmap)); + + // Only change the selection if we actually got a positive hit. + // This is necessary so that selection isn't lost if the panel reappears later due to e.g. unapplying some filter criteria that made it disappear in the first place. + if (newSelection != null) + CurrentSelection = newSelection; } } - } - private double distanceBetween(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / DrawableCarouselItem.MAX_HEIGHT); + // Transfer the previous flag states across to the new models. + if (ExpandedBeatmapSet != null) setExpandedSet(ExpandedBeatmapSet); + if (ExpandedGroup != null) setExpandedGroup(ExpandedGroup); - private void playSpinSample(double distance) - { - var chan = spinSample?.GetChannel(); + foreach (var item in Scroll.Panels.OfType().Where(p => p.Item != null)) + updateVisibleBeatmaps((GroupedBeatmapSet)item.Item!.Model, item); + } - if (chan != null) + private void selectRecommendedDifficultyForBeatmapSet(GroupedBeatmapSet set) + { + // Selecting a set isn't valid – let's re-select the first visible difficulty. + if (grouping.SetItems.TryGetValue(set, out var items)) { - chan.Frequency.Value = 1f + Math.Min(1f, distance / visibleSetsCount); - chan.Play(); + var beatmaps = items.Select(i => i.Model).OfType(); + RequestRecommendedSelection(beatmaps); } - - randomSelectSample?.Play(); } - private void select(CarouselItem? item) + /// + /// If we don't have a selection and there's a single beatmap set returned, select it for the user. + /// + private void attemptSelectSingleFilteredResult() { - if (!AllowSelection) - return; + var items = GetCarouselItems(); - if (item == null) return; + if (items == null || items.Count == 0) return; - item.State.Value = CarouselItemState.Selected; - } + BeatmapSetInfo? beatmapSetInfo = null; - private FilterCriteria activeCriteria; + foreach (var item in items) + { + if (item.Model is GroupedBeatmap groupedBeatmap) + { + var beatmapInfo = groupedBeatmap.Beatmap; - protected ScheduledDelegate? PendingFilter; + if (beatmapSetInfo == null) + { + beatmapSetInfo = beatmapInfo.BeatmapSet!; + continue; + } - public bool AllowSelection = true; + // Found a beatmap with a different beatmap set, abort. + if (!beatmapSetInfo.Equals(beatmapInfo.BeatmapSet)) + return; + } + } - /// - /// Half the height of the visible content. - /// - /// This is different from the height of .displayableContent, since - /// the beatmap carousel bleeds into the and the - /// - /// - private float visibleHalfHeight => (DrawHeight + BleedBottom + BleedTop) / 2; + var beatmaps = items.Select(i => i.Model).OfType(); - /// - /// The position of the lower visible bound with respect to the current scroll position. - /// - private float visibleBottomBound => (float)(Scroll.Current + DrawHeight + BleedBottom); + // do not request recommended selection if the user already had selected a difficulty within the single filtered beatmap set, + // as it could change the difficulty that will be selected + var preexistingSelection = beatmaps.FirstOrDefault(b => b.Equals(CurrentSelection as GroupedBeatmap)); - /// - /// The position of the upper visible bound with respect to the current scroll position. - /// - private float visibleUpperBound => (float)(Scroll.Current - BleedTop); + if (preexistingSelection != null) + { + // the selection might not have an item associated with it, if it was fully filtered away previously + // in this case, request to reselect it + if (CurrentSelectionItem == null) + RequestSelection(preexistingSelection); - public void FlushPendingFilterOperations() - { - if (!IsLoaded) return; - - if (PendingFilter?.Completed == false) - { - applyActiveCriteria(false); - Update(); } - } - public void Filter(FilterCriteria? newCriteria) - { - if (newCriteria != null) - activeCriteria = newCriteria; - - applyActiveCriteria(true); + RequestRecommendedSelection(beatmaps); } - private bool beatmapsSplitOut; + protected override bool CheckValidForGroupSelection(CarouselItem item) => item.Model is GroupDefinition; - private void applyActiveCriteria(bool debounce) + protected override bool CheckValidForSetSelection(CarouselItem item) { - PendingFilter?.Cancel(); - PendingFilter = null; - - if (debounce) - PendingFilter = Scheduler.AddDelayed(perform, 250); - else + switch (item.Model) { - // if initial load is not yet finished, this will be run inline in loadBeatmapSets to ensure correct order of operation. - if (!BeatmapSetsLoaded) - PendingFilter = Schedule(perform); - else - perform(); - } - - void perform() - { - PendingFilter = null; - - if ((activeCriteria.Sort == SortMode.Difficulty) != beatmapsSplitOut) - { - loadNewRoot(); - return; - } + case GroupedBeatmapSet: + return true; - root.Filter(activeCriteria); - itemsCache.Invalidate(); + case GroupedBeatmap: + return !grouping.BeatmapSetsGroupedTogether; - ScrollToSelected(true); + case GroupDefinition: + return false; - FilterApplied?.Invoke(); + default: + throw new ArgumentException($"Unsupported model type {item.Model}"); } } - private void invalidateAfterChange() + private void setExpandedGroup(GroupDefinition? group) { - itemsCache.Invalidate(); + if (ExpandedGroup != null) + setExpansionStateOfGroup(ExpandedGroup, false); - if (!Scroll.UserScrolling) - ScrollToSelected(true); + ExpandedGroup = group; - BeatmapSetsChanged?.Invoke(); + if (ExpandedGroup != null) + setExpansionStateOfGroup(ExpandedGroup, true); } - private float? scrollTarget; - - /// - /// Scroll to the current . - /// - /// - /// Whether the scroll position should immediately be shifted to the target, delegating animation to visible panels. - /// This should be true for operations like filtering - where panels are changing visibility state - to avoid large jumps in animation. - /// - public void ScrollToSelected(bool immediate = false) => - pendingScrollOperation = immediate ? PendingScrollOperation.Immediate : PendingScrollOperation.Standard; - - #region Button selection logic - - public bool OnPressed(KeyBindingPressEvent e) + private void setExpansionStateOfGroup(GroupDefinition group, bool expanded) { - switch (e.Action) + if (grouping.GroupItems.TryGetValue(group, out var items)) { - case GlobalAction.SelectNext: - case GlobalAction.ActivateNextSet: - SelectNext(1, e.Action == GlobalAction.ActivateNextSet); - return true; + if (expanded) + { + foreach (var i in items) + { + switch (i.Model) + { + case GroupDefinition: + i.IsExpanded = true; + break; + + case GroupedBeatmapSet groupedSet: + // Case where there are set headers, header should be visible + // and items should use the set's expanded state. + i.IsVisible = true; + setExpansionStateOfSetItems(groupedSet, i.IsExpanded); + break; + + default: + // Case where there are no set headers, all items should be visible. + if (!grouping.BeatmapSetsGroupedTogether) + i.IsVisible = true; + break; + } + } + } + else + { + foreach (var i in items) + { + switch (i.Model) + { + case GroupDefinition: + i.IsExpanded = false; + break; - case GlobalAction.SelectPrevious: - case GlobalAction.ActivatePreviousSet: - SelectNext(-1, e.Action == GlobalAction.ActivatePreviousSet); - return true; + default: + i.IsVisible = false; + break; + } + } + } } - - return false; } - public void OnReleased(KeyBindingReleaseEvent e) + private void setExpandedSet(GroupedBeatmapSet set) { - } + GroupedBeatmapSet? lastExpandedSet = ExpandedBeatmapSet; - #endregion + // It's important that we update the stored ExpandedBeatmapSet even when + // sets are not grouped together. + // + // This is stored when selection is changed and used later to ensure correct + // visual states are achieved (see call of this method in `HandleFilterCompleted` + // for an important case). + ExpandedBeatmapSet = set; - protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) - { - // handles the vertical size of the carousel changing (ie. on window resize when aspect ratio has changed). - if (invalidation.HasFlag(Invalidation.DrawSize)) - itemsCache.Invalidate(); + if (!grouping.BeatmapSetsGroupedTogether) + return; - return base.OnInvalidate(invalidation, source); + setExpansionStateOfSetItems(lastExpandedSet, false); + setExpansionStateOfSetItems(ExpandedBeatmapSet, true); } - protected override void Update() + private void setExpansionStateOfSetItems(GroupedBeatmapSet? set, bool expanded) { - base.Update(); + if (set == null) + return; - bool revalidateItems = !itemsCache.IsValid; + bool canMakeVisible = !grouping.GroupItems.Any() || ExpandedGroup == set.Group; - // First we iterate over all non-filtered carousel items and populate their - // vertical position data. - if (revalidateItems) + if (grouping.SetItems.TryGetValue(set, out var items)) { - updateYPositions(); - - if (visibleItems.Count == 0) + foreach (var i in items) { - noResultsPlaceholder.Filter = activeCriteria; - noResultsPlaceholder.Show(); + if (i.Model is GroupedBeatmapSet) + i.IsExpanded = expanded; + else + i.IsVisible = canMakeVisible && expanded; } - else - noResultsPlaceholder.Hide(); } + } - // if there is a pending scroll action we apply it without animation and transfer the difference in position to the panels. - // this is intentionally applied before updating the visible range below, to avoid animating new items (sourced from pool) from locations off-screen, as it looks bad. - if (pendingScrollOperation != PendingScrollOperation.None) - updateScrollPosition(); - - // This data is consumed to find the currently displayable range. - // This is the range we want to keep drawables for, and should exceed the visible range slightly to avoid drawable churn. - var newDisplayRange = getDisplayRange(); + protected override double? GetScrollTarget() + { + double? target = base.GetScrollTarget(); - // If the filtered items or visible range has changed, pooling requirements need to be checked. - // This involves fetching new items from the pool, returning no-longer required items. - if (revalidateItems || newDisplayRange != displayedRange) + // if the base implementation returned null, it means that the keyboard selection has been filtered out and is no longer visible + // attempt a fallback to other possibly expanded panels (set first, then group) + if (target == null) { - displayedRange = newDisplayRange; - - if (visibleItems.Count > 0) - { - var toDisplay = visibleItems.GetRange(displayedRange.first, displayedRange.last - displayedRange.first + 1); + CarouselItem? targetItem = null; - foreach (var panel in Scroll) - { - Debug.Assert(panel.Item != null); + if (ExpandedBeatmapSet != null && grouping.ItemMap.TryGetValue(ExpandedBeatmapSet, out var setItem)) + targetItem = setItem.item; - if (toDisplay.Remove(panel.Item)) - { - // panel already displayed. - continue; - } + if (targetItem == null && ExpandedGroup != null && grouping.ItemMap.TryGetValue(ExpandedGroup, out var groupItem)) + targetItem = groupItem.item; - // panel loaded as drawable but not required by visible range. - // remove but only if too far off-screen - if (panel.Y + panel.DrawHeight < visibleUpperBound - distance_offscreen_before_unload || panel.Y > visibleBottomBound + distance_offscreen_before_unload) - expirePanelImmediately(panel); - } + target = targetItem?.CarouselYPosition; + } - // Add those items within the previously found index range that should be displayed. - foreach (var item in toDisplay) - { - var panel = setPool.Get(); + return target; + } - panel.Item = item; - panel.Y = item.CarouselYPosition; + #endregion - Scroll.Add(panel); - } - } - } + #region Audio - // Update externally controlled state of currently visible items (e.g. x-offset and opacity). - // This is a per-frame update on all drawable panels. - foreach (DrawableCarouselItem item in Scroll) - { - updateItem(item); + private Sample? sampleChangeDifficulty; + private Sample? sampleChangeSet; + private Sample? sampleToggleGroup; - Debug.Assert(item.Item != null); + private double audioFeedbackLastPlaybackTime; - if (item.Item.Visible) - { - bool isSelected = item.Item.State.Value == CarouselItemState.Selected; + private void loadSamples(AudioManager audio) + { + sampleChangeDifficulty = audio.Samples.Get(@"SongSelect/select-difficulty"); + sampleChangeSet = audio.Samples.Get(@"SongSelect/select-expand"); + sampleToggleGroup = audio.Samples.Get(@"SongSelect/select-group"); - bool hasPassedSelection = item.Item.CarouselYPosition < selectedBeatmapSet?.CarouselYPosition; + spinSample = audio.Samples.Get("SongSelect/random-spin"); + randomSelectSample = audio.Samples.Get(@"SongSelect/select-random"); + } - // Cheap way of doing animations when entering / exiting song select. - const double half_time = 50; - const float panel_x_offset_when_inactive = 200; + private void playActivationSound(CarouselItem item) + { + if (Time.Current - audioFeedbackLastPlaybackTime >= OsuGameBase.SAMPLE_DEBOUNCE_TIME) + { + switch (item.Model) + { + case GroupDefinition: + sampleToggleGroup?.Play(); + return; - if (isSelected || AllowSelection) - { - item.Alpha = (float)Interpolation.DampContinuously(item.Alpha, 1, half_time, Clock.ElapsedFrameTime); - item.X = (float)Interpolation.DampContinuously(item.X, 0, half_time, Clock.ElapsedFrameTime); - } - else - { - item.Alpha = (float)Interpolation.DampContinuously(item.Alpha, 0, half_time, Clock.ElapsedFrameTime); - item.X = (float)Interpolation.DampContinuously(item.X, panel_x_offset_when_inactive, half_time, Clock.ElapsedFrameTime); - } + case GroupedBeatmapSet: + sampleChangeSet?.Play(); + return; - Scroll.ChangeChildDepth(item, hasPassedSelection ? -item.Item.CarouselYPosition : item.Item.CarouselYPosition); + case GroupedBeatmap: + sampleChangeDifficulty?.Play(); + return; } - if (item is DrawableCarouselBeatmapSet set) - { - for (int i = 0; i < set.DrawableBeatmaps.Count; i++) - updateItem(set.DrawableBeatmaps[i], item); - } + audioFeedbackLastPlaybackTime = Time.Current; } } - private static void expirePanelImmediately(DrawableCarouselItem panel) - { - // may want a fade effect here (could be seen if a huge change happens, like a set with 20 difficulties becomes selected). - panel.ClearTransforms(); - panel.Expire(); - } + #endregion - private readonly CarouselBoundsItem carouselBoundsItem = new CarouselBoundsItem(); + #region Animation - private (int firstIndex, int lastIndex) getDisplayRange() - { - // Find index range of all items that should be on-screen - carouselBoundsItem.CarouselYPosition = visibleUpperBound - distance_offscreen_to_preload; - int firstIndex = visibleItems.BinarySearch(carouselBoundsItem); - if (firstIndex < 0) firstIndex = ~firstIndex; + /// + /// Moves non-selected beatmaps to the right, hiding off-screen. + /// + public bool VisuallyFocusSelected { get; set; } - carouselBoundsItem.CarouselYPosition = visibleBottomBound + distance_offscreen_to_preload; - int lastIndex = visibleItems.BinarySearch(carouselBoundsItem); - if (lastIndex < 0) lastIndex = ~lastIndex; + private float selectionFocusOffset; - // as we can't be 100% sure on the size of individual carousel drawables, - // always play it safe and extend bounds by one. - firstIndex = Math.Max(0, firstIndex - 1); - lastIndex = Math.Clamp(lastIndex + 1, firstIndex, Math.Max(0, visibleItems.Count - 1)); + protected override void Update() + { + base.Update(); - return (firstIndex, lastIndex); + selectionFocusOffset = (float)Interpolation.DampContinuously(selectionFocusOffset, VisuallyFocusSelected ? 300 : 0, 100, Time.Elapsed); } - private CarouselBeatmapSet? createCarouselSet(BeatmapSetInfo beatmapSet) + protected override float GetPanelXOffset(Drawable panel) { - // This can be moved to the realm query if required using: - // .Filter("DeletePending == false && Protected == false && ANY Beatmaps.Hidden == false") - // - // As long as we are detaching though, it makes more sense to do it here as adding to the realm query has an overhead - // as seen at https://github.com/realm/realm-dotnet/discussions/2773#discussioncomment-2004275. - if (beatmapSet.Beatmaps.All(b => b.Hidden)) - return null; + return base.GetPanelXOffset(panel) + (((ICarouselPanel)panel).Selected.Value ? 0 : selectionFocusOffset); + } - var set = new CarouselBeatmapSet(beatmapSet) - { - GetRecommendedBeatmap = beatmaps => GetRecommendedBeatmap?.Invoke(beatmaps) - }; + #endregion - foreach (var c in set.Beatmaps) - { - c.State.ValueChanged += state => - { - if (state.NewValue == CarouselItemState.Selected) - { - selectedBeatmapSet = set; - SelectionChanged?.Invoke(c.BeatmapInfo); + #region Filtering - itemsCache.Invalidate(); - ScrollToSelected(); - } - }; - } + public FilterCriteria? Criteria { get; private set; } - return set; - } + private ScheduledDelegate? loadingDebounce; - /// - /// Computes the target Y positions for every item in the carousel. - /// - /// The Y position of the currently selected item. - private void updateYPositions() + public void Filter(FilterCriteria criteria, bool showLoadingImmediately = false) { - visibleItems.Clear(); - - float currentY = visibleHalfHeight; + bool resetDisplay = grouping.BeatmapSetsGroupedTogether != BeatmapCarouselFilterGrouping.ShouldGroupBeatmapsTogether(criteria); - scrollTarget = null; + Criteria = criteria; - foreach (CarouselItem item in root.Items) + loadingDebounce ??= Scheduler.AddDelayed(() => { - if (item.Filtered.Value) - continue; + if (loading.State.Value == Visibility.Visible) + return; - switch (item) - { - case CarouselBeatmapSet set: - { - bool isSelected = item.State.Value == CarouselItemState.Selected; + Scroll.FadeColour(OsuColour.Gray(0.5f), 1000, Easing.OutQuint); + loading.Show(); + }, showLoadingImmediately ? 0 : 250); - float padding = isSelected ? 5 : -5; + FilterAsync(resetDisplay).ContinueWith(_ => Schedule(() => + { + loadingDebounce?.Cancel(); + loadingDebounce = null; - if (isSelected) - // double padding because we want to cancel the negative padding from the last item. - currentY += padding * 2; + Scroll.FadeColour(OsuColour.Gray(1f), 500, Easing.OutQuint); + loading.Hide(); + })); + } - visibleItems.Add(set); - set.CarouselYPosition = currentY; + protected override Task> FilterAsync(bool clearExistingPanels = false) + { + if (Criteria == null) + return Task.FromResult(Enumerable.Empty()); - if (isSelected) - { - // scroll position at currentY makes the set panel appear at the very top of the carousel's screen space - // move down by half of visible height (height of the carousel's visible extent, including semi-transparent areas) - // then reapply the top semi-transparent area (because carousel's screen space starts below it) - scrollTarget = currentY + DrawableCarouselBeatmapSet.HEIGHT - visibleHalfHeight + BleedTop; + return base.FilterAsync(clearExistingPanels); + } - foreach (var b in set.Beatmaps) - { - if (!b.Visible) - continue; + #endregion - if (b.State.Value == CarouselItemState.Selected) - { - scrollTarget += b.TotalHeight / 2; - break; - } + #region Fetches for grouping support - scrollTarget += b.TotalHeight; - } - } + [Resolved] + private RealmAccess realm { get; set; } = null!; - currentY += set.TotalHeight + padding; - break; - } - } - } + [Resolved] + private IAPIProvider api { get; set; } = null!; - currentY += visibleHalfHeight; + /// + /// FOOTGUN WARNING: this being sorted on the realm side before detaching is IMPORTANT. + /// realm supports sorting as an internal operation, and realm's implementation of string sorting does NOT match dotnet's + /// with respect to treatment of punctuation characters like - or _, among others. + /// All other places that show lists of collections also use the realm-side sorting implementation, + /// because they use the sorting operation inside subscription queries for efficient drawable management, + /// so this usage kind of has to follow suit. + /// + protected virtual List GetAllCollections() => realm.Run(r => r.All().OrderBy(c => c.Name).AsEnumerable().Detach()); - Scroll.ScrollContent.Height = currentY; + protected virtual Dictionary GetBeatmapInfoGuidToTopRankMapping(FilterCriteria criteria) => realm.Run(r => + { + var topRankMapping = new Dictionary(); - itemsCache.Validate(); + var allLocalScores = r.GetAllLocalScoresForUser(criteria.LocalUserId) + .Filter($@"{nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $0", criteria.Ruleset?.ShortName) + .OrderByDescending(s => s.TotalScore) + .ThenBy(s => s.Date); - // update and let external consumers know about selection loss. - if (BeatmapSetsLoaded && AllowSelection) + foreach (var score in allLocalScores) { - bool selectionLost = selectedBeatmapSet != null && selectedBeatmapSet.State.Value != CarouselItemState.Selected; + Debug.Assert(score.BeatmapInfo != null); - if (selectionLost) - { - selectedBeatmapSet = null; - SelectionChanged?.Invoke(null); - } - } - } + if (topRankMapping.ContainsKey(score.BeatmapInfo.ID)) + continue; - private bool firstScroll = true; + topRankMapping[score.BeatmapInfo.ID] = score.Rank; + } - private void updateScrollPosition() - { - if (scrollTarget != null) - { - if (firstScroll) - { - // reduce movement when first displaying the carousel. - Scroll.ScrollTo(scrollTarget.Value - 200, false); - firstScroll = false; - } + return topRankMapping; + }); - switch (pendingScrollOperation) - { - case PendingScrollOperation.Standard: - Scroll.ScrollTo(scrollTarget.Value); - break; - - case PendingScrollOperation.Immediate: - - // in order to simplify animation logic, rather than using the animated version of ScrollTo, - // we take the difference in scroll height and apply to all visible panels. - // this avoids edge cases like when the visible panels is reduced suddenly, causing ScrollContainer - // to enter clamp-special-case mode where it animates completely differently to normal. - float scrollChange = (float)(scrollTarget.Value - Scroll.Current); - Scroll.ScrollTo(scrollTarget.Value, false); - foreach (var i in Scroll) - i.Y += scrollChange; - break; - } + /// + /// Note that calling .ToHashSet() below has two purposes: + /// one being performance of contain checks in filtering code, + /// another being slightly better thread safety (as could be mutated during async filtering). + /// + protected HashSet GetFavouriteBeatmapSets() => api.LocalUserState.FavouriteBeatmapSets.ToHashSet(); - pendingScrollOperation = PendingScrollOperation.None; - } - } + #endregion - /// - /// Computes the x-offset of currently visible items. Makes the carousel appear round. - /// - /// - /// Vertical distance from the center of the carousel container - /// ranging from -1 to 1. - /// - /// Half the height of the carousel container. - private static float offsetX(float dist, float halfHeight) - { - // The radius of the circle the carousel moves on. - const float circle_radius = 3; - float discriminant = MathF.Max(0, circle_radius * circle_radius - dist * dist); - float x = (circle_radius - MathF.Sqrt(discriminant)) * halfHeight; + #region Drawable pooling - return 125 + x; - } + private readonly DrawablePool beatmapPanelPool = new DrawablePool(100); + private readonly DrawablePool standalonePanelPool = new DrawablePool(100); + private readonly DrawablePool setPanelPool = new DrawablePool(100); + private readonly DrawablePool groupPanelPool = new DrawablePool(100); + private readonly DrawablePool starsGroupPanelPool = new DrawablePool(11); + private readonly DrawablePool ranksGroupPanelPool = new DrawablePool(9); + private readonly DrawablePool statusGroupPanelPool = new DrawablePool(8); - /// - /// Update an item's x position and multiplicative alpha based on its y position and - /// the current scroll position. - /// - /// The item to be updated. - /// For nested items, the parent of the item to be updated. - private void updateItem(DrawableCarouselItem item, DrawableCarouselItem? parent = null) + private void setupPools() { - Vector2 posInScroll = Scroll.ScrollContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre); - float itemDrawY = posInScroll.Y - visibleUpperBound; - float dist = Math.Abs(1f - itemDrawY / visibleHalfHeight); - - // adjusting the item's overall X position can cause it to become masked away when - // child items (difficulties) are still visible. - item.Header.X = offsetX(dist, visibleHalfHeight) - (parent?.X ?? 0); + AddInternal(statusGroupPanelPool); + AddInternal(ranksGroupPanelPool); + AddInternal(starsGroupPanelPool); + AddInternal(groupPanelPool); + AddInternal(beatmapPanelPool); + AddInternal(standalonePanelPool); + AddInternal(setPanelPool); } - private enum PendingScrollOperation + protected override bool CheckModelEquality(object? x, object? y) { - None, - Standard, - Immediate, - } + // In the confines of the carousel logic, we assume that CurrentSelection (and all items) are using non-stale + // BeatmapInfo reference, and that we can match based on beatmap / beatmapset (GU)IDs. + // + // If there's a case where updates don't come in as expected, diagnosis should start from BeatmapStore, ensuring + // it is doing a Replace operation on the list. If it is, then check the local handling in beatmapSetsChanged + // before changing matching requirements here. - /// - /// A carousel item strictly used for binary search purposes. - /// - private class CarouselBoundsItem : CarouselItem - { - public override DrawableCarouselItem CreateDrawableRepresentation() => throw new NotImplementedException(); - } + if (x is GroupedBeatmapSet groupedSetX && y is GroupedBeatmapSet groupedSetY) + return groupedSetX.Equals(groupedSetY); - private class CarouselRoot : CarouselGroupEagerSelect - { - // May only be null during construction (State.Value set causes PerformSelection to be triggered). - private readonly BeatmapCarousel? carousel; + if (x is GroupedBeatmap groupedBeatmapX && y is GroupedBeatmap groupedBeatmapY) + return groupedBeatmapX.Equals(groupedBeatmapY); - public readonly Dictionary> BeatmapSetsByID = new Dictionary>(); + // `BeatmapInfo` is no longer used directly in carousel items, but in rare circumstances still is used for model equality comparisons + // (see `beatmapSetsChanged()` deletion handling logic, which aims to find a beatmap close to the just-deleted one, disregarding grouping concerns) + if (x is BeatmapInfo beatmapInfoX && y is BeatmapInfo beatmapInfoY) + return beatmapInfoX.Equals(beatmapInfoY); - public CarouselRoot(BeatmapCarousel carousel) - { - // root should always remain selected. if not, PerformSelection will not be called. - State.Value = CarouselItemState.Selected; - State.ValueChanged += _ => State.Value = CarouselItemState.Selected; + if (x is StarDifficultyGroupDefinition starX && y is StarDifficultyGroupDefinition starY) + return starX.Equals(starY); - this.carousel = carousel; - } + if (x is RankDisplayGroupDefinition rankX && y is RankDisplayGroupDefinition rankY) + return rankX.Equals(rankY); - public override void AddItem(CarouselItem i) - { - CarouselBeatmapSet set = (CarouselBeatmapSet)i; - if (BeatmapSetsByID.TryGetValue(set.BeatmapSet.ID, out var sets)) - sets.Add(set); - else - BeatmapSetsByID.Add(set.BeatmapSet.ID, new List { set }); + if (x is RankedStatusGroupDefinition statusX && y is RankedStatusGroupDefinition statusY) + return statusX.Equals(statusY); - base.AddItem(i); - } + // NOTE: this branch must be AFTER all branches that compare `GroupDefinition` subtypes! + // this is an optimisation measure. any subclass of `GroupDefinition` will pass the `is GroupDefinition` check, + // and testing a subclass of `GroupDefinition` against any other `GroupDefinition` (or subclass thereof) + // will result in a casting cascade of `Equals(GroupDefinition) -> Equals(object) -> Equals(GroupDefinitionSubClass)` + // (that last one only if the type check passes) + if (x is GroupDefinition groupX && y is GroupDefinition groupY) + return groupX.Equals(groupY); + + return base.CheckModelEquality(x, y); + } - /// - /// A special method to handle replace operations (general for updating a beatmap). - /// Avoids event-driven selection flip-flopping during the remove/add process. - /// - /// The beatmap set to be replaced. - /// All new items to replace the removed beatmap set. - /// All removed items, for any further processing. - public IEnumerable ReplaceItem(BeatmapSetInfo oldItem, List newItems) + protected override Drawable GetDrawableForDisplay(CarouselItem item) + { + switch (item.Model) { - var previousSelection = (LastSelected as CarouselBeatmapSet)?.Beatmaps - .FirstOrDefault(s => s.State.Value == CarouselItemState.Selected) - ?.BeatmapInfo; + case RankedStatusGroupDefinition: + return statusGroupPanelPool.Get(); - bool wasSelected = previousSelection?.BeatmapSet?.ID == oldItem.ID; + case StarDifficultyGroupDefinition: + return starsGroupPanelPool.Get(); - // Without doing this, the removal of the old beatmap will cause carousel's eager selection - // logic to invoke, causing one unnecessary selection. - DisableSelection = true; - var removedSets = RemoveItemsByID(oldItem.ID); - DisableSelection = false; + case RankDisplayGroupDefinition: + return ranksGroupPanelPool.Get(); - foreach (var set in newItems) - AddItem(set); + case GroupDefinition: + return groupPanelPool.Get(); - // Check if we can/need to maintain our current selection. - if (wasSelected) - { - CarouselBeatmap? matchingBeatmap = newItems.SelectMany(s => s.Beatmaps) - .FirstOrDefault(b => b.BeatmapInfo.ID == previousSelection?.ID); + case GroupedBeatmap: + if (!grouping.BeatmapSetsGroupedTogether) + return standalonePanelPool.Get(); - if (matchingBeatmap != null) - matchingBeatmap.State.Value = CarouselItemState.Selected; - } + return beatmapPanelPool.Get(); - return removedSets; + case GroupedBeatmapSet groupedBeatmapSet: + var setPanel = setPanelPool.Get(); + updateVisibleBeatmaps(groupedBeatmapSet, setPanel); + return setPanel; } - public IEnumerable RemoveItemsByID(Guid beatmapSetID) - { - if (BeatmapSetsByID.TryGetValue(beatmapSetID, out var carouselBeatmapSets)) - { - foreach (var set in carouselBeatmapSets) - RemoveItem(set); + throw new InvalidOperationException(); + } - return carouselBeatmapSets; - } + private void updateVisibleBeatmaps(GroupedBeatmapSet groupedBeatmapSet, PanelBeatmapSet setPanel) + { + HashSet visibleBeatmaps = []; + if (grouping.SetItems.TryGetValue(groupedBeatmapSet, out var visibleItems)) + visibleBeatmaps = visibleItems.Where(i => i.Model is GroupedBeatmap).Select(i => ((GroupedBeatmap)i.Model).Beatmap).ToHashSet(); - return Enumerable.Empty(); - } + setPanel.VisibleBeatmaps.Value = visibleBeatmaps; + } - public override void RemoveItem(CarouselItem i) - { - CarouselBeatmapSet set = (CarouselBeatmapSet)i; - BeatmapSetsByID.Remove(set.BeatmapSet.ID); + #endregion - base.RemoveItem(i); - } + #region Random selection handling - protected override void PerformSelection() - { - if (LastSelected == null) - carousel?.SelectNextRandom(); - else - base.PerformSelection(); - } - } + private readonly Bindable randomAlgorithm = new Bindable(); + private readonly HashSet previouslyVisitedRandomBeatmaps = new HashSet(); + private readonly List randomHistory = new List(); + + private Sample? spinSample; + private Sample? randomSelectSample; - public partial class CarouselScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler + public bool NextRandom() { - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + var carouselItems = GetCarouselItems(); - public CarouselScrollContainer() - { - // size is determined by the carousel itself, due to not all content necessarily being loaded. - ScrollContent.AutoSizeAxes = Axes.None; + if (carouselItems?.Any() != true) + return false; - // the scroll container may get pushed off-screen by global screen changes, but we still want panels to display outside of the bounds. - Masking = false; - } + var selectionBefore = CurrentSelectionItem; + var beatmapBefore = selectionBefore?.Model as GroupedBeatmap; - #region Absolute scrolling + bool success; - private bool absoluteScrolling; + if (beatmapBefore != null) + { + // keep track of visited beatmaps and sets for rewind + randomHistory.Add(beatmapBefore); + // keep track of visited beatmaps for "RandomPermutation" random tracking. + // note that this is reset when we run out of beatmaps, while `randomHistory` is not. + previouslyVisitedRandomBeatmaps.Add(beatmapBefore.Beatmap); + } - protected override bool IsDragging => base.IsDragging || absoluteScrolling; + if (grouping.BeatmapSetsGroupedTogether) + success = nextRandomSet(); + else + success = nextRandomBeatmap(); - public bool OnPressed(KeyBindingPressEvent e) + if (!success) { - switch (e.Action) - { - case GlobalAction.AbsoluteScrollSongList: - beginAbsoluteScrolling(e); - return true; - } - + if (beatmapBefore != null) + randomHistory.RemoveAt(randomHistory.Count - 1); return false; } - public void OnReleased(KeyBindingReleaseEvent e) + // CurrentSelectionItem won't be valid until UpdateAfterChildren. + // We probably want to fix this at some point since a few places are working-around this quirk. + ScheduleAfterChildren(() => { - switch (e.Action) - { - case GlobalAction.AbsoluteScrollSongList: - endAbsoluteScrolling(); - break; - } - } + if (selectionBefore != null && CurrentSelectionItem != null) + playSpinSample(visiblePanelCountBetweenItems(selectionBefore, CurrentSelectionItem)); + }); + + return true; + } - protected override bool OnMouseDown(MouseDownEvent e) + private bool nextRandomBeatmap() + { + ICollection visibleBeatmaps = ExpandedGroup != null && grouping.GroupItems.TryGetValue(ExpandedGroup, out var groupItems) + // In the case of grouping, users expect random to only operate on the expanded group. + // This is going to incur some overhead as we don't have a group-beatmapset mapping currently. + // + // If this becomes an issue, we could either store a mapping, or run the random algorithm many times + // using the `SetItems` method until we get a group HIT. + ? groupItems.Select(i => i.Model).OfType().ToArray() + : GetCarouselItems()!.Select(i => i.Model).OfType().ToArray(); + + GroupedBeatmap beatmap; + + switch (randomAlgorithm.Value) { - if (e.Button == MouseButton.Right) + case RandomSelectAlgorithm.RandomPermutation: { - // To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over. - if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) + ICollection notYetVisitedBeatmaps = visibleBeatmaps.ExceptBy(previouslyVisitedRandomBeatmaps, gb => gb.Beatmap).ToList(); + + if (!notYetVisitedBeatmaps.Any()) + { + previouslyVisitedRandomBeatmaps.ExceptWith(visibleBeatmaps.Select(b => b.Beatmap)); + notYetVisitedBeatmaps = visibleBeatmaps; + if (CurrentSelection is GroupedBeatmap groupedBeatmap) + notYetVisitedBeatmaps = notYetVisitedBeatmaps.Except([groupedBeatmap]).ToList(); + } + + if (notYetVisitedBeatmaps.Count == 0) return false; - beginAbsoluteScrolling(e); + beatmap = notYetVisitedBeatmaps.ElementAt(RNG.Next(notYetVisitedBeatmaps.Count)); + break; } - return base.OnMouseDown(e); - } + case RandomSelectAlgorithm.Random: + beatmap = visibleBeatmaps.ElementAt(RNG.Next(visibleBeatmaps.Count)); + break; - protected override void OnMouseUp(MouseUpEvent e) - { - if (e.Button == MouseButton.Right) - endAbsoluteScrolling(); - base.OnMouseUp(e); + default: + throw new ArgumentOutOfRangeException(); } - protected override bool OnMouseMove(MouseMoveEvent e) + RequestSelection(beatmap); + return true; + } + + private bool nextRandomSet() + { + ICollection visibleGroupedSets = ExpandedGroup != null && grouping.GroupItems.TryGetValue(ExpandedGroup, out var groupItems) + // In the case of grouping, users expect random to only operate on the expanded group. + // This is going to incur some overhead as we don't have a group-beatmapset mapping currently. + // + // If this becomes an issue, we could either store a mapping, or run the random algorithm many times + // using the `SetItems` method until we get a group HIT. + ? groupItems.Select(i => i.Model).OfType().ToArray() + // This is the fastest way to retrieve sets for randomisation. + : grouping.SetItems.Keys; + + GroupedBeatmapSet set; + + switch (randomAlgorithm.Value) { - if (absoluteScrolling) + case RandomSelectAlgorithm.RandomPermutation: { - ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); - return true; + ICollection notYetVisitedSets = + visibleGroupedSets.ExceptBy(previouslyVisitedRandomBeatmaps.Select(b => b.BeatmapSet!), groupedSet => groupedSet.BeatmapSet).ToList(); + + if (!notYetVisitedSets.Any()) + { + previouslyVisitedRandomBeatmaps.ExceptWith(visibleGroupedSets.SelectMany(setUnderGrouping => setUnderGrouping.BeatmapSet.Beatmaps)); + notYetVisitedSets = visibleGroupedSets; + if (CurrentSelection is GroupedBeatmap groupedBeatmap) + notYetVisitedSets = notYetVisitedSets.ExceptBy([groupedBeatmap.Beatmap.BeatmapSet!], groupedSet => groupedSet.BeatmapSet).ToList(); + } + + if (notYetVisitedSets.Count == 0) + return false; + + set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count)); + break; } - return base.OnMouseMove(e); - } + case RandomSelectAlgorithm.Random: + set = visibleGroupedSets.ElementAt(RNG.Next(visibleGroupedSets.Count)); + break; - private void beginAbsoluteScrolling(UIEvent e) - { - ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); - absoluteScrolling = true; + default: + throw new ArgumentOutOfRangeException(); } - private void endAbsoluteScrolling() => absoluteScrolling = false; + selectRecommendedDifficultyForBeatmapSet(set); + return true; + } + + public bool PreviousRandom() + { + var carouselItems = GetCarouselItems(); - #endregion + if (carouselItems?.Any() != true) + return false; - protected override ScrollbarContainer CreateScrollbar(Direction direction) + while (randomHistory.Any()) { - return new PaddedScrollbar(); - } + var previousBeatmap = randomHistory[^1]; + randomHistory.RemoveAt(randomHistory.Count - 1); - protected partial class PaddedScrollbar : OsuScrollbar - { - public PaddedScrollbar() - : base(Direction.Vertical) + // when going back through rewind history, we may no longer be in the same grouping mode. + // the user wants to go back to the beatmap first and foremost, so the most important thing is to find a panel that corresponds to the beatmap. + // going back to the same group is a nice-to-have, but a secondary concern. + var previousBeatmapItem = carouselItems.Where(i => i.Model is GroupedBeatmap gb && gb.Beatmap.Equals(previousBeatmap.Beatmap)) + .MaxBy(i => ((GroupedBeatmap)i.Model).Group == previousBeatmap.Group); + + if (previousBeatmapItem == null) + return false; + + if (CurrentSelection is GroupedBeatmap groupedBeatmap) { + if (randomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) + previouslyVisitedRandomBeatmaps.Remove(groupedBeatmap.Beatmap); + + if (CurrentSelectionItem == null) + playSpinSample(0); + else + playSpinSample(visiblePanelCountBetweenItems(previousBeatmapItem, CurrentSelectionItem)); } + + RequestSelection((GroupedBeatmap)previousBeatmapItem.Model); + return true; } - private const float top_padding = 10; - private const float bottom_padding = 70; + return false; + } - protected override float ToScrollbarPosition(double scrollPosition) - { - if (Precision.AlmostEquals(0, ScrollableExtent)) - return 0; + private double visiblePanelCountBetweenItems(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / PanelBeatmapSet.HEIGHT); - return (float)(top_padding + (ScrollbarMovementExtent - (top_padding + bottom_padding)) * (scrollPosition / ScrollableExtent)); - } + private void playSpinSample(double distance) + { + var chan = spinSample?.GetChannel(); - protected override float FromScrollbarPosition(float scrollbarPosition) + if (chan != null) { - if (Precision.AlmostEquals(0, ScrollbarMovementExtent)) - return 0; - - return (float)(ScrollableExtent * ((scrollbarPosition - top_padding) / (ScrollbarMovementExtent - (top_padding + bottom_padding)))); + chan.Frequency.Value = 1f + Math.Clamp(distance / 200, 0, 1); + chan.Play(); } + + randomSelectSample?.Play(); + } + + #endregion + } + + /// + /// Defines a grouping header for a set of carousel items. + /// + public record GroupDefinition + { + /// + /// The order of this group in the carousel, sorted using ascending order. + /// + public int Order { get; } + + /// + /// The title of this group. + /// + public LocalisableString Title { get; } + + private readonly string uncasedTitle; + + public GroupDefinition(int order, LocalisableString title) + { + Order = order; + Title = title; + uncasedTitle = title.ToLower().GetLocalised(LocalisationParameters.DEFAULT); } + + public virtual bool Equals(GroupDefinition? other) => uncasedTitle == other?.uncasedTitle; + + public override int GetHashCode() => HashCode.Combine(uncasedTitle); } + + /// + /// Defines a grouping header for a set of carousel items grouped by star difficulty. + /// + public record StarDifficultyGroupDefinition(int Order, LocalisableString Title, StarDifficulty Difficulty) : GroupDefinition(Order, Title); + + /// + /// Defines a grouping header for a set of carousel items grouped by achieved rank. + /// + public record RankDisplayGroupDefinition(ScoreRank Rank) : GroupDefinition(-(int)Rank, Rank.GetLocalisableDescription()); + + /// + /// Defines a grouping header for a set of carousel items grouped by ranked status. + /// + public record RankedStatusGroupDefinition(int Order, BeatmapOnlineStatus Status) : GroupDefinition(Order, Status.GetLocalisableDescription()); + + /// + /// Used to represent a portion of a under a . + /// The purpose of this model is to support splitting beatmap sets apart when the active grouping mode demands it. + /// + public record GroupedBeatmapSet([UsedImplicitly] GroupDefinition? Group, BeatmapSetInfo BeatmapSet); + + /// + /// Used to represent a under a . + /// The purpose of this model is to support showing multiple copies of a beatmap, which can occur if a beatmap appears in multiple groups + /// (most prominently, collections group mode). + /// + public record GroupedBeatmap(GroupDefinition? Group, BeatmapInfo Beatmap); } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/Select/BeatmapCarouselFilterGrouping.cs similarity index 83% rename from osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs rename to osu.Game/Screens/Select/BeatmapCarouselFilterGrouping.cs index 675bb455a554..8db8ed5cb09b 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/Select/BeatmapCarouselFilterGrouping.cs @@ -11,11 +11,10 @@ using osu.Game.Collections; using osu.Game.Graphics.Carousel; using osu.Game.Scoring; -using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Utils; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public class BeatmapCarouselFilterGrouping : ICarouselFilter { @@ -26,6 +25,8 @@ public class BeatmapCarouselFilterGrouping : ICarouselFilter /// public int BeatmapItemsCount { get; private set; } + public IDictionary ItemMap => itemMap; + /// /// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection. /// @@ -36,6 +37,7 @@ public class BeatmapCarouselFilterGrouping : ICarouselFilter /// public IDictionary> GroupItems => groupMap; + private Dictionary itemMap = new Dictionary(); private Dictionary> setMap = new Dictionary>(); private Dictionary> groupMap = new Dictionary>(); @@ -49,6 +51,7 @@ public async Task> Run(IEnumerable items, Cance return await Task.Run(() => { // preallocate space for the new mappings using last known estimates + var newItemMap = new Dictionary(itemMap.Count); var newSetMap = new Dictionary>(setMap.Count); var newGroupMap = new Dictionary>(groupMap.Count); @@ -127,6 +130,7 @@ void addItem(CarouselItem i) { newItems.Add(i); + newItemMap[i.Model] = (i, newItems.Count - 1); currentGroupItems?.Add(i); currentSetItems?.Add(i); @@ -136,6 +140,7 @@ void addItem(CarouselItem i) cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Exchange(ref itemMap, newItemMap); Interlocked.Exchange(ref setMap, newSetMap); Interlocked.Exchange(ref groupMap, newGroupMap); BeatmapItemsCount = displayedBeatmapsCount; @@ -209,7 +214,7 @@ private List getGroups(List items, FilterCriteria cr case GroupMode.Collections: { var collections = GetCollections(); - return getGroupsBy(b => defineGroupByCollection(b, collections), items); + return defineGroupsByCollection(items, collections); } case GroupMode.MyMaps: @@ -396,29 +401,56 @@ private IEnumerable defineGroupBySource(string source) return new GroupDefinition(0, source).Yield(); } - private IEnumerable defineGroupByCollection(BeatmapInfo beatmap, List collections) + private List defineGroupsByCollection(List carouselItems, List allCollections) { - bool anyCollections = false; + Dictionary groupMappings = new Dictionary(); + // this is a pre-built mapping of MD5s to a list of collections in which this MD5 is found in. + // the reason to pre-build this is that `BeatmapCollection.BeatmapMD5Hashes` is a list and therefore a naive implementation would be slow, + // particularly in edge cases where most beatmaps are in more than one collection. + Dictionary> md5ToCollectionsMap = new Dictionary>(); - for (int i = 0; i < collections.Count; i++) + for (int i = 0; i < allCollections.Count; i++) { - var collection = collections[i]; - - if (collection.BeatmapMD5Hashes.Contains(beatmap.MD5Hash)) + var collection = allCollections[i]; + // NOTE: the ordering of the incoming collection list is significant and needs to be preserved. + // the fallback to ordering by name cannot be relied on. + // see xmldoc of `BeatmapCarousel.GetAllCollections()`. + var groupDefinition = new GroupDefinition(i, collection.Name); + groupMappings[groupDefinition] = new GroupMapping(groupDefinition, []); + + foreach (string md5 in collection.BeatmapMD5Hashes) { - // NOTE: the ordering of the incoming collection list is significant and needs to be preserved. - // the fallback to ordering by name cannot be relied on. - // see xmldoc of `BeatmapCarousel.GetAllCollections()`. - yield return new GroupDefinition(i, collection.Name); + if (!md5ToCollectionsMap.TryGetValue(md5, out var collections)) + md5ToCollectionsMap[md5] = collections = new List(); - anyCollections = true; + collections.Add(groupDefinition); } } - if (anyCollections) - yield break; + var notInCollection = new GroupDefinition(int.MaxValue, "Not in collection"); + groupMappings[notInCollection] = new GroupMapping(notInCollection, []); + + foreach (var item in carouselItems) + { + var beatmap = (BeatmapInfo)item.Model; + + // as a side note, even reading the `MD5Hash` off a realm model is slow if done enough times, + // so it definitely helps that thanks to the mapping it needs to only be retrieved once + if (md5ToCollectionsMap.TryGetValue(beatmap.MD5Hash, out var collections)) + { + foreach (var collection in collections) + groupMappings[collection].ItemsInGroup.Add(item); + } + else + groupMappings[notInCollection].ItemsInGroup.Add(item); + } - yield return new GroupDefinition(int.MaxValue, "Not in collection"); + return groupMappings.Values + // safety against potentially empty eagerly-initialised groups + // (could happen if user has a collection with MD5s of maps that aren't locally available) + .Where(mapping => mapping.ItemsInGroup.Count > 0) + .OrderBy(mapping => mapping.Group!.Order) + .ToList(); } private IEnumerable defineGroupByOwnMaps(BeatmapInfo beatmap, int? localUserId, string? localUserUsername) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs b/osu.Game/Screens/Select/BeatmapCarouselFilterMatching.cs similarity index 95% rename from osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs rename to osu.Game/Screens/Select/BeatmapCarouselFilterMatching.cs index 2a132a8a4524..5011166bb149 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs +++ b/osu.Game/Screens/Select/BeatmapCarouselFilterMatching.cs @@ -8,10 +8,9 @@ using System.Threading.Tasks; using osu.Game.Beatmaps; using osu.Game.Graphics.Carousel; -using osu.Game.Screens.Select; using osu.Game.Utils; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public class BeatmapCarouselFilterMatching : ICarouselFilter { @@ -42,7 +41,7 @@ private IEnumerable matchItems(IEnumerable items, Fi if (beatmap.Hidden) continue; - if (!checkCriteriaMatch(beatmap, criteria)) + if (!CheckCriteriaMatch(beatmap, criteria)) continue; countMatching++; @@ -52,14 +51,14 @@ private IEnumerable matchItems(IEnumerable items, Fi BeatmapItemsCount = countMatching; } - private static bool checkCriteriaMatch(BeatmapInfo beatmap, FilterCriteria criteria) + public static bool CheckCriteriaMatch(BeatmapInfo beatmap, FilterCriteria criteria) { bool match = criteria.Ruleset == null || beatmap.AllowGameplayWithRuleset(criteria.Ruleset!, criteria.AllowConvertedBeatmaps); - if (beatmap.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) + if (criteria.SelectedBeatmapSet != null) { // only check ruleset equality or convertability for selected beatmap - return match; + return beatmap.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true && match; } if (!match) return false; diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs similarity index 98% rename from osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs rename to osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs index e9d65f710802..60e94299b26a 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs +++ b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs @@ -8,11 +8,10 @@ using System.Threading.Tasks; using osu.Game.Beatmaps; using osu.Game.Graphics.Carousel; -using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Utils; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public class BeatmapCarouselFilterSorting : ICarouselFilter { diff --git a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs index e3981c85f0fd..c681f89cd03e 100644 --- a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs +++ b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs @@ -5,19 +5,20 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Game.Beatmaps; +using osu.Game.Localisation; using osu.Game.Overlays.Dialog; using osu.Game.Scoring; namespace osu.Game.Screens.Select { - public partial class BeatmapClearScoresDialog : DangerousActionDialog + public partial class BeatmapClearScoresDialog : DeletionDialog { [Resolved] private ScoreManager scoreManager { get; set; } = null!; public BeatmapClearScoresDialog(BeatmapInfo beatmapInfo, Action? onCompletion = null) { - BodyText = $"All local scores on {beatmapInfo.GetDisplayTitle()}"; + BodyText = DialogStrings.BeatmapClearScoresBodyText(beatmapInfo.GetDisplayTitle()); DangerousAction = () => { Task.Run(() => scoreManager.Delete(beatmapInfo)) diff --git a/osu.Game/Screens/Select/BeatmapDetailArea.cs b/osu.Game/Screens/Select/BeatmapDetailArea.cs deleted file mode 100644 index 595b86924b29..000000000000 --- a/osu.Game/Screens/Select/BeatmapDetailArea.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; - -namespace osu.Game.Screens.Select -{ - public abstract partial class BeatmapDetailArea : Container - { - private const float details_padding = 10; - - private WorkingBeatmap beatmap; - - public virtual WorkingBeatmap Beatmap - { - get => beatmap; - set - { - beatmap = value; - - Details.BeatmapInfo = value?.BeatmapInfo; - } - } - - public readonly BeatmapDetails Details; - - protected Bindable CurrentTab => tabControl.Current; - - protected Bindable CurrentModsFilter => tabControl.CurrentModsFilter; - - private readonly Container content; - protected override Container Content => content; - - private readonly BeatmapDetailAreaTabControl tabControl; - - protected BeatmapDetailArea() - { - AddRangeInternal(new Drawable[] - { - content = new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = BeatmapDetailAreaTabControl.HEIGHT }, - Child = Details = new BeatmapDetails - { - RelativeSizeAxes = Axes.X, - Alpha = 0, - Margin = new MarginPadding { Top = details_padding }, - } - }, - tabControl = new BeatmapDetailAreaTabControl - { - RelativeSizeAxes = Axes.X, - TabItems = CreateTabItems(), - OnFilter = OnTabChanged, - }, - }); - } - - /// - /// Refreshes the currently-displayed details. - /// - public virtual void Refresh() - { - } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - Details.Height = Math.Min(DrawHeight - details_padding * 3 - BeatmapDetailAreaTabControl.HEIGHT, 450); - } - - /// - /// Invoked when a new tab is selected. - /// - /// The tab that was selected. - /// Whether the currently-selected mods should be considered. - protected virtual void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) - { - switch (tab) - { - case BeatmapDetailAreaDetailTabItem: - Details.Show(); - break; - - default: - Details.Hide(); - break; - } - } - - /// - /// Creates the tabs to be displayed. - /// - /// The tabs. - protected virtual BeatmapDetailAreaTabItem[] CreateTabItems() => new BeatmapDetailAreaTabItem[] - { - new BeatmapDetailAreaDetailTabItem(), - }; - } -} diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs deleted file mode 100644 index 4ff2600a7263..000000000000 --- a/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs +++ /dev/null @@ -1,10 +0,0 @@ -// 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.Screens.Select -{ - public class BeatmapDetailAreaDetailTabItem : BeatmapDetailAreaTabItem - { - public override string Name => "Details"; - } -} diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs deleted file mode 100644 index 8dbe5b8bea65..000000000000 --- a/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs +++ /dev/null @@ -1,22 +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; - -namespace osu.Game.Screens.Select -{ - public class BeatmapDetailAreaLeaderboardTabItem : BeatmapDetailAreaTabItem - where TScope : Enum - { - public override string Name => Scope.ToString(); - - public override bool FilterableByMods => true; - - public readonly TScope Scope; - - public BeatmapDetailAreaLeaderboardTabItem(TScope scope) - { - Scope = scope; - } - } -} diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs b/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs deleted file mode 100644 index f9dab2bb1d74..000000000000 --- a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using System.Collections.Generic; -using osuTK.Graphics; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Framework.Graphics.Shapes; - -namespace osu.Game.Screens.Select -{ - public partial class BeatmapDetailAreaTabControl : Container - { - public const float HEIGHT = 24; - - public Bindable Current - { - get => tabs.Current; - set => tabs.Current = value; - } - - public Bindable CurrentModsFilter - { - get => modsCheckbox.Current; - set => modsCheckbox.Current = value; - } - - public Action OnFilter; // passed the selected tab and if mods is checked - - public IReadOnlyList TabItems - { - get => tabs.Items; - set => tabs.Items = value; - } - - private readonly OsuTabControlCheckbox modsCheckbox; - private readonly OsuTabControl tabs; - private readonly Container tabsContainer; - - public BeatmapDetailAreaTabControl() - { - Height = HEIGHT; - - Children = new Drawable[] - { - new Box - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = 1, - Colour = Color4.White.Opacity(0.2f), - }, - tabsContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Child = tabs = new OsuTabControl - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - IsSwitchable = true, - }, - }, - modsCheckbox = new OsuTabControlCheckbox - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Text = @"Selected Mods", - Alpha = 0, - }, - }; - - tabs.Current.ValueChanged += _ => invokeOnFilter(); - modsCheckbox.Current.ValueChanged += _ => invokeOnFilter(); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - modsCheckbox.AccentColour = tabs.AccentColour = colour.YellowLight; - } - - private void invokeOnFilter() - { - OnFilter?.Invoke(tabs.Current.Value, modsCheckbox.Current.Value); - - if (tabs.Current.Value.FilterableByMods) - { - modsCheckbox.FadeTo(1, 200, Easing.OutQuint); - tabsContainer.Padding = new MarginPadding { Right = 100 }; - } - else - { - modsCheckbox.FadeTo(0, 200, Easing.OutQuint); - tabsContainer.Padding = new MarginPadding(); - } - } - } -} diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs deleted file mode 100644 index 7b7a93d6ee5b..000000000000 --- a/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; - -namespace osu.Game.Screens.Select -{ - public abstract class BeatmapDetailAreaTabItem : IEquatable - { - /// - /// The name of this tab, to be displayed in the tab control. - /// - public abstract string Name { get; } - - /// - /// Whether the contents of this tab can be filtered by the user's currently-selected mods. - /// - public virtual bool FilterableByMods => false; - - public override string ToString() => Name; - - public bool Equals(BeatmapDetailAreaTabItem other) - { - if (ReferenceEquals(null, other)) return false; - if (ReferenceEquals(this, other)) return true; - - return Name == other.Name; - } - - public override int GetHashCode() - { - return Name != null ? Name.GetHashCode() : 0; - } - } -} diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs deleted file mode 100644 index 6a6a4cddf3e5..000000000000 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ /dev/null @@ -1,278 +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.Linq; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.BeatmapSet; -using osu.Game.Resources.Localisation.Web; -using osu.Game.Screens.Select.Details; -using osuTK; - -namespace osu.Game.Screens.Select -{ - public partial class BeatmapDetails : Container - { - private const float spacing = 10; - private const float transition_duration = 250; - - private readonly UserRatings ratingsDisplay; - private readonly MetadataSection description, source, tags; - private readonly Container failRetryContainer; - private readonly FailRetryGraph failRetryGraph; - private readonly LoadingLayer loading; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - [Resolved] - private SongSelect? songSelect { get; set; } - - private IBeatmapInfo? beatmapInfo; - - private APIFailTimes? failTimes; - - private int[]? ratings; - - public IBeatmapInfo? BeatmapInfo - { - get => beatmapInfo; - set - { - if (value == beatmapInfo) return; - - beatmapInfo = value; - - var onlineInfo = beatmapInfo as IBeatmapOnlineInfo; - var onlineSetInfo = beatmapInfo?.BeatmapSet as IBeatmapSetOnlineInfo; - - failTimes = onlineInfo?.FailTimes; - ratings = onlineSetInfo?.Ratings; - - Scheduler.AddOnce(updateStatistics); - } - } - - public BeatmapDetails() - { - CornerRadius = 10; - Masking = true; - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Colour4.Black.Opacity(0.3f), - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = spacing }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension() - }, - Content = new[] - { - new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Width = 0.5f, - Spacing = new Vector2(spacing), - Padding = new MarginPadding { Right = spacing / 2 }, - Children = new[] - { - new DetailBox().WithChild(new OnlineViewContainer(string.Empty) - { - RelativeSizeAxes = Axes.X, - Height = 134, - Padding = new MarginPadding { Horizontal = spacing, Top = spacing }, - Child = ratingsDisplay = new UserRatings - { - RelativeSizeAxes = Axes.Both, - }, - }), - }, - }, - new OsuScrollContainer - { - RelativeSizeAxes = Axes.X, - Height = 250, - Width = 0.5f, - ScrollbarVisible = false, - Padding = new MarginPadding { Left = spacing / 2 }, - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - LayoutDuration = transition_duration, - LayoutEasing = Easing.OutQuad, - Children = new[] - { - description = new MetadataSectionDescription(query => songSelect?.Search(query)), - source = new MetadataSectionSource(query => songSelect?.Search(query)), - tags = new MetadataSectionMapperTags(query => songSelect?.Search(query)), - }, - }, - }, - }, - }, - }, - new Drawable[] - { - failRetryContainer = new OnlineViewContainer("Sign in to view more details") - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = BeatmapsetsStrings.ShowInfoPointsOfFailure, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), - }, - failRetryGraph = new FailRetryGraph - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 14 + spacing / 2 }, - }, - }, - }, - } - } - }, - loading = new LoadingLayer(true) - }; - } - - private void updateStatistics() - { - description.Metadata = BeatmapInfo?.DifficultyName ?? string.Empty; - source.Metadata = BeatmapInfo?.Metadata.Source ?? string.Empty; - tags.Metadata = BeatmapInfo?.Metadata.Tags ?? string.Empty; - - // failTimes may have been previously fetched - if (ratings != null && failTimes != null) - { - updateMetrics(); - return; - } - - // for now, let's early abort if an OnlineID is not present (should have been populated at import time). - if (BeatmapInfo == null || BeatmapInfo.OnlineID <= 0 || api.State.Value == APIState.Offline) - { - updateMetrics(); - return; - } - - var requestedBeatmap = BeatmapInfo; - - var lookup = new GetBeatmapRequest(requestedBeatmap); - - lookup.Success += res => - { - Schedule(() => - { - if (beatmapInfo != requestedBeatmap) - // the beatmap has been changed since we started the lookup. - return; - - ratings = res.BeatmapSet?.Ratings; - failTimes = res.FailTimes; - - updateMetrics(); - }); - }; - - lookup.Failure += _ => - { - Schedule(() => - { - if (beatmapInfo != requestedBeatmap) - // the beatmap has been changed since we started the lookup. - return; - - updateMetrics(); - }); - }; - - api.Queue(lookup); - loading.Show(); - } - - private void updateMetrics() - { - bool hasMetrics = (failTimes?.Retries?.Any() ?? false) || (failTimes?.Fails?.Any() ?? false); - - if (ratings?.Any() ?? false) - { - ratingsDisplay.Ratings = ratings; - ratingsDisplay.FadeIn(transition_duration); - } - else - { - // loading or just has no data server-side. - ratingsDisplay.Ratings = new int[10]; - ratingsDisplay.FadeTo(0.25f, transition_duration); - } - - if (hasMetrics) - { - failRetryGraph.FailTimes = failTimes; - failRetryContainer.FadeIn(transition_duration); - } - else - { - failRetryGraph.FailTimes = new APIFailTimes - { - Fails = new int[100], - Retries = new int[100], - }; - } - - loading.Hide(); - } - - private partial class DetailBox : Container - { - private readonly Container content; - protected override Container Content => content; - - public DetailBox() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - InternalChildren = new Drawable[] - { - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - }; - } - } - } -} diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.Header.cs b/osu.Game/Screens/Select/BeatmapDetailsArea.Header.cs similarity index 98% rename from osu.Game/Screens/SelectV2/BeatmapDetailsArea.Header.cs rename to osu.Game/Screens/Select/BeatmapDetailsArea.Header.cs index f4a223985d45..b20254a35c9c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.Header.cs +++ b/osu.Game/Screens/Select/BeatmapDetailsArea.Header.cs @@ -12,11 +12,11 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Online.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapDetailsArea { @@ -76,6 +76,7 @@ private void load(OsuConfigManager config) { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.X, Text = UserInterfaceStrings.SelectedMods, Height = 30f, // Eyeballed to make spacing match. Because shear is silly and implemented in different ways between dropdown and button. diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs b/osu.Game/Screens/Select/BeatmapDetailsArea.WedgeSelector.cs similarity index 89% rename from osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs rename to osu.Game/Screens/Select/BeatmapDetailsArea.WedgeSelector.cs index b5cdeee792bd..9a5db9b818ed 100644 --- a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs +++ b/osu.Game/Screens/Select/BeatmapDetailsArea.WedgeSelector.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -13,10 +14,11 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapDetailsArea { @@ -25,6 +27,8 @@ public partial class WedgeSelector : TabControl { private Circle strip = null!; + private Bindable currentLanguage = null!; + protected override Dropdown? CreateDropdown() => null; protected override TabItem CreateTabItem(T value) => new TabItem(value); @@ -37,7 +41,7 @@ public WedgeSelector(float spacing) } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider, OsuGameBase game) { AddInternal(strip = new Circle { @@ -49,6 +53,8 @@ private void load(OverlayColourProvider colourProvider) foreach (var type in Enum.GetValues()) AddItem(type); + + currentLanguage = game.CurrentLanguage.GetBoundCopy(); } protected override void LoadComplete() @@ -57,11 +63,14 @@ protected override void LoadComplete() Current.BindValueChanged(_ => updateDisplay()); - ScheduleAfterChildren(() => + currentLanguage.BindValueChanged(_ => { - updateDisplay(); - FinishTransforms(true); - }); + ScheduleAfterChildren(() => + { + updateDisplay(); + FinishTransforms(true); + }); + }, true); } private void updateDisplay() diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.cs b/osu.Game/Screens/Select/BeatmapDetailsArea.cs similarity index 98% rename from osu.Game/Screens/SelectV2/BeatmapDetailsArea.cs rename to osu.Game/Screens/Select/BeatmapDetailsArea.cs index 7a2068b0cfb2..f83fd1539de5 100644 --- a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.cs +++ b/osu.Game/Screens/Select/BeatmapDetailsArea.cs @@ -6,7 +6,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { /// /// The left portion of the song select screen which houses the metadata or leaderboards wedge, along with controls diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs deleted file mode 100644 index 79564167f42c..000000000000 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ /dev/null @@ -1,517 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Localisation; -using osu.Framework.Logging; -using osu.Game.Configuration; -using osu.Game.Extensions; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; -using osu.Game.Graphics.Containers; -using osu.Game.Resources.Localisation.Web; -using osu.Game.Utils; - -namespace osu.Game.Screens.Select -{ - public partial class BeatmapInfoWedge : VisibilityContainer - { - public const float BORDER_THICKNESS = 2.5f; - private const float shear_width = 36.75f; - - private const float transition_duration = 250; - - private static readonly Vector2 wedged_container_shear = new Vector2(shear_width / SongSelect.WEDGE_HEIGHT, 0); - - [Resolved] - private IBindable ruleset { get; set; } - - protected Container DisplayedContent { get; private set; } - - protected WedgeInfoText Info { get; private set; } - - public BeatmapInfoWedge() - { - Shear = wedged_container_shear; - Masking = true; - BorderColour = new Color4(221, 255, 255, 255); - BorderThickness = BORDER_THICKNESS; - Alpha = 0; - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = new Color4(130, 204, 255, 150), - Radius = 15, - Roundness = 15, - }; - } - - [BackgroundDependencyLoader] - private void load() - { - ruleset.BindValueChanged(_ => updateDisplay()); - } - - private const double animation_duration = 800; - - protected override void PopIn() - { - this.MoveToX(0, animation_duration, Easing.OutQuint); - this.FadeIn(transition_duration); - } - - protected override void PopOut() - { - this.MoveToX(-100, animation_duration, Easing.In); - this.FadeOut(transition_duration * 2, Easing.In); - } - - private WorkingBeatmap beatmap; - - public WorkingBeatmap Beatmap - { - get => beatmap; - set - { - if (beatmap == value) return; - - beatmap = value; - - updateDisplay(); - } - } - - public override bool IsPresent => base.IsPresent || DisplayedContent == null; // Visibility is updated in the LoadComponentAsync callback - - private Container loadingInfo; - - private void updateDisplay() - { - Scheduler.AddOnce(perform); - - void perform() - { - void removeOldInfo() - { - State.Value = beatmap == null ? Visibility.Hidden : Visibility.Visible; - - DisplayedContent?.FadeOut(transition_duration); - DisplayedContent?.Expire(); - DisplayedContent = null; - } - - if (beatmap == null) - { - removeOldInfo(); - return; - } - - LoadComponentAsync(loadingInfo = new Container - { - RelativeSizeAxes = Axes.Both, - Shear = -Shear, - Depth = DisplayedContent?.Depth + 1 ?? 0, - Children = new Drawable[] - { - new BeatmapInfoWedgeBackground(beatmap), - Info = new WedgeInfoText(beatmap, ruleset.Value), - } - }, loaded => - { - // ensure we are the most recent loaded wedge. - if (loaded != loadingInfo) return; - - removeOldInfo(); - Add(DisplayedContent = loaded); - }); - } - } - - public partial class WedgeInfoText : Container - { - public OsuSpriteText VersionLabel { get; private set; } - public OsuSpriteText TitleLabel { get; private set; } - public OsuSpriteText ArtistLabel { get; private set; } - public FillFlowContainer MapperContainer { get; private set; } - - private Container difficultyColourBar; - private StarRatingDisplay starRatingDisplay; - - private ILocalisedBindableString titleBinding; - private ILocalisedBindableString artistBinding; - private FillFlowContainer infoLabelContainer; - private Container bpmLabelContainer; - private Container lengthLabelContainer; - - private readonly WorkingBeatmap working; - private readonly RulesetInfo ruleset; - - [Resolved] - private IBindable> mods { get; set; } - - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } - - [Resolved] - private OsuColour colours { get; set; } - - private ModSettingChangeTracker settingChangeTracker; - - public WedgeInfoText(WorkingBeatmap working, RulesetInfo userRuleset) - { - this.working = working; - ruleset = userRuleset ?? working.BeatmapInfo.Ruleset; - } - - private CancellationTokenSource cancellationSource; - private IBindable starDifficulty; - - [BackgroundDependencyLoader] - private void load(LocalisationManager localisation) - { - var beatmapInfo = working.BeatmapInfo; - var metadata = beatmapInfo.Metadata; - - RelativeSizeAxes = Axes.Both; - - titleBinding = localisation.GetLocalisedBindableString(new RomanisableString(metadata.TitleUnicode, metadata.Title)); - artistBinding = localisation.GetLocalisedBindableString(new RomanisableString(metadata.ArtistUnicode, metadata.Artist)); - - const float top_height = 0.7f; - - Children = new Drawable[] - { - difficultyColourBar = new Container - { - RelativeSizeAxes = Axes.Y, - Width = 20f, - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Width = top_height, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Alpha = 0.5f, - X = top_height, - Width = 1 - top_height, - } - } - }, - new FillFlowContainer - { - Name = "Topleft-aligned metadata", - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Top = 10, Left = 25, Right = shear_width * 2.5f }, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Children = new Drawable[] - { - VersionLabel = new TruncatingSpriteText - { - Text = beatmapInfo.DifficultyName, - Font = OsuFont.GetFont(size: 24, italics: true), - RelativeSizeAxes = Axes.X, - }, - } - }, - new FillFlowContainer - { - Name = "Topright-aligned metadata", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Top = 14, Right = shear_width / 2 }, - AutoSizeAxes = Axes.Both, - Shear = wedged_container_shear, - Spacing = new Vector2(0f, 5f), - Children = new Drawable[] - { - starRatingDisplay = new StarRatingDisplay(default, animated: true) - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Shear = -wedged_container_shear, - Alpha = 0f, - }, - new BeatmapSetOnlineStatusPill - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Shear = -wedged_container_shear, - TextSize = 11, - TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, - Status = beatmapInfo.Status, - Alpha = string.IsNullOrEmpty(beatmapInfo.DifficultyName) ? 0 : 1 - } - } - }, - new FillFlowContainer - { - Name = "Centre-aligned metadata", - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopLeft, - Y = -7, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Left = 25, Right = shear_width }, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Children = new Drawable[] - { - TitleLabel = new TruncatingSpriteText - { - Current = { BindTarget = titleBinding }, - Font = OsuFont.GetFont(size: 28, italics: true), - RelativeSizeAxes = Axes.X, - }, - ArtistLabel = new TruncatingSpriteText - { - Current = { BindTarget = artistBinding }, - Font = OsuFont.GetFont(size: 17, italics: true), - RelativeSizeAxes = Axes.X, - }, - MapperContainer = new FillFlowContainer - { - Margin = new MarginPadding { Top = 10 }, - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Child = getMapper(metadata), - }, - infoLabelContainer = new FillFlowContainer - { - Margin = new MarginPadding { Top = 8 }, - Spacing = new Vector2(20, 0), - AutoSizeAxes = Axes.Both, - } - } - } - }; - - addInfoLabels(); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - starRatingDisplay.DisplayedStars.BindValueChanged(s => - { - difficultyColourBar.Colour = colours.ForStarDifficulty(s.NewValue); - }, true); - - starDifficulty = difficultyCache.GetBindableDifficulty(working.BeatmapInfo, (cancellationSource = new CancellationTokenSource()).Token); - starDifficulty.BindValueChanged(s => - { - starRatingDisplay.Current.Value = s.NewValue; - - // Don't roll the counter on initial display (but still allow it to roll on applying mods etc.) - if (!starRatingDisplay.IsPresent) - starRatingDisplay.FinishTransforms(true); - - starRatingDisplay.FadeIn(transition_duration); - }); - - mods.BindValueChanged(m => - { - settingChangeTracker?.Dispose(); - - refreshBPMAndLengthLabel(); - - settingChangeTracker = new ModSettingChangeTracker(m.NewValue); - settingChangeTracker.SettingChanged += _ => refreshBPMAndLengthLabel(); - }, true); - } - - private void addInfoLabels() - { - if (working.Beatmap?.HitObjects.Any() != true) - return; - - try - { - IBeatmap playableBeatmap; - - try - { - // Try to get the beatmap with the user's ruleset - playableBeatmap = working.GetPlayableBeatmap(ruleset, Array.Empty()); - } - catch (BeatmapInvalidForRulesetException) - { - // Can't be converted to the user's ruleset, so use the beatmap's own ruleset - playableBeatmap = working.GetPlayableBeatmap(working.BeatmapInfo.Ruleset, Array.Empty()); - } - - infoLabelContainer.Children = new Drawable[] - { - lengthLabelContainer = new Container - { - AutoSizeAxes = Axes.Both, - }, - bpmLabelContainer = new Container - { - AutoSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(20, 0), - Children = playableBeatmap.GetStatistics().Select(s => new InfoLabel(s)).ToArray() - } - }; - } - catch (Exception e) - { - Logger.Error(e, "Could not load beatmap successfully!"); - } - } - - private void refreshBPMAndLengthLabel() - { - var beatmap = working.Beatmap; - - if (beatmap == null || bpmLabelContainer == null) - return; - - double rate = ModUtils.CalculateRateWithMods(mods.Value); - - int bpmMax = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMaximum, rate); - int bpmMin = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMinimum, rate); - int mostCommonBPM = FormatUtils.RoundBPM(60000 / beatmap.GetMostCommonBeatLength(), rate); - - string labelText = bpmMin == bpmMax - ? $"{bpmMin}" - : $"{bpmMin}-{bpmMax} (mostly {mostCommonBPM})"; - - bpmLabelContainer.Child = new InfoLabel(new BeatmapStatistic - { - Name = BeatmapsetsStrings.ShowStatsBpm, - CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Bpm), - Content = labelText - }); - - double drainLength = Math.Round(beatmap.CalculateDrainLength() / rate); - double hitLength = Math.Round(beatmap.BeatmapInfo.Length / rate); - - lengthLabelContainer.Child = new InfoLabel(new BeatmapStatistic - { - Name = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()), - CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), - Content = hitLength.ToFormattedDuration().ToString(), - }); - } - - private Drawable getMapper(BeatmapMetadata metadata) - { - if (string.IsNullOrEmpty(metadata.Author.Username)) - return Empty(); - - return new LinkFlowContainer(s => - { - 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) - { - base.Dispose(isDisposing); - settingChangeTracker?.Dispose(); - cancellationSource?.Cancel(); - } - - public partial class InfoLabel : Container, IHasTooltip - { - public LocalisableString TooltipText { get; } - - internal BeatmapStatistic Statistic { get; } - - public InfoLabel(BeatmapStatistic statistic) - { - Statistic = statistic; - TooltipText = statistic.Name; - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] - { - new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(20), - Children = new[] - { - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"441288"), - Icon = FontAwesome.Solid.Square, - Rotation = 45, - }, - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"f7dd55"), - Icon = FontAwesome.Regular.Circle, - Size = new Vector2(0.7f) - }, - statistic.CreateIcon().With(i => - { - i.Anchor = Anchor.Centre; - i.Origin = Anchor.Centre; - i.RelativeSizeAxes = Axes.Both; - i.Colour = Color4Extensions.FromHex(@"f7dd55"); - i.Size = new Vector2(0.6f); - }), - } - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Colour = new Color4(255, 221, 85, 255), - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 17), - Margin = new MarginPadding { Left = 30 }, - Text = statistic.Content, - } - }; - } - } - } - } -} diff --git a/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs b/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs deleted file mode 100644 index 50ec446c4fea..000000000000 --- a/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs +++ /dev/null @@ -1,65 +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 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 partial class BeatmapInfoWedgeBackground : CompositeDrawable - { - private readonly IWorkingBeatmap beatmap; - - public BeatmapInfoWedgeBackground(IWorkingBeatmap beatmap) - { - this.beatmap = beatmap; - } - - [BackgroundDependencyLoader] - private void load() - { - RelativeSizeAxes = Axes.Both; - - InternalChild = new BufferedContainer(cachedFrameBuffer: 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/SelectV2/BeatmapLeaderboardScore.Tooltip.cs b/osu.Game/Screens/Select/BeatmapLeaderboardScore.Tooltip.cs similarity index 99% rename from osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.Tooltip.cs rename to osu.Game/Screens/Select/BeatmapLeaderboardScore.Tooltip.cs index 1f92699887d3..bd3b6f16b371 100644 --- a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.Tooltip.cs +++ b/osu.Game/Screens/Select/BeatmapLeaderboardScore.Tooltip.cs @@ -34,7 +34,7 @@ using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapLeaderboardScore { diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs b/osu.Game/Screens/Select/BeatmapLeaderboardScore.cs similarity index 98% rename from osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs rename to osu.Game/Screens/Select/BeatmapLeaderboardScore.cs index 5013150f05c5..bb96c97d520a 100644 --- a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs +++ b/osu.Game/Screens/Select/BeatmapLeaderboardScore.cs @@ -16,7 +16,6 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.Graphics; @@ -33,7 +32,6 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; -using osu.Game.Screens.Select; using osu.Game.Users; using osu.Game.Users.Drawables; using osu.Game.Utils; @@ -41,7 +39,7 @@ using osuTK.Graphics; using CommonStrings = osu.Game.Localisation.CommonStrings; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public sealed partial class BeatmapLeaderboardScore : OsuClickableContainer, IHasContextMenu, IHasCustomTooltip { @@ -60,6 +58,7 @@ public sealed partial class BeatmapLeaderboardScore : OsuClickableContainer, IHa public int? Rank { get; init; } public HighlightType? Highlight { get; init; } + public Action? ShowReplay { get; init; } [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -77,7 +76,7 @@ public sealed partial class BeatmapLeaderboardScore : OsuClickableContainer, IHa private OsuConfigManager config { get; set; } = null!; [Resolved] - private Clipboard? clipboard { get; set; } + private OsuGame? game { get; set; } [Resolved] private IAPIProvider api { get; set; } = null!; @@ -625,10 +624,15 @@ MenuItem[] IHasContextMenu.ContextMenuItems items.Add(new OsuMenuItem(SongSelectStrings.UseTheseMods, MenuItemType.Highlighted, () => SelectedMods.Value = copyableMods)); if (Score.OnlineID > 0) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{Score.OnlineID}"))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard($@"{api.Endpoints.WebsiteUrl}/scores/{Score.OnlineID}"))); if (Score.Files.Count <= 0) return items.ToArray(); + if (items.Count > 0) + items.Add(new OsuMenuItemSpacer()); + + if (ShowReplay != null) + items.Add(new OsuMenuItem(SongSelectStrings.WatchReplay, MenuItemType.Standard, () => ShowReplay.Invoke(Score))); items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(Score))); items.Add(new OsuMenuItem(Resources.Localisation.Web.CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score)))); diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs b/osu.Game/Screens/Select/BeatmapLeaderboardWedge.cs similarity index 92% rename from osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs rename to osu.Game/Screens/Select/BeatmapLeaderboardWedge.cs index 8aa3a0516fc7..4373b7b0736c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs +++ b/osu.Game/Screens/Select/BeatmapLeaderboardWedge.cs @@ -33,11 +33,11 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Screens.Play.Leaderboards; using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapLeaderboardWedge : VisibilityContainer { @@ -237,27 +237,31 @@ public void RefetchScores() SetState(LeaderboardState.Retrieving); + var fetchScope = Scope.Value; + refetchOperation?.Cancel(); refetchOperation = Scheduler.AddDelayed(() => { var fetchBeatmapInfo = beatmap.Value.BeatmapInfo; var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; - var fetchSorting = Scope.Value == BeatmapLeaderboardScope.Local ? Sorting.Value : LeaderboardSortMode.Score; + var fetchSorting = fetchScope == BeatmapLeaderboardScope.Local ? Sorting.Value : LeaderboardSortMode.Score; // For now, we forcefully refresh to keep things simple. // In the future, removing this requirement may be deemed useful, but will need ample testing of edge case scenarios // (like returning from gameplay after setting a new score, returning to song select after main menu). - leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope.Value, FilterBySelectedMods.Value ? mods.Value.ToArray() : null, fetchSorting), + leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, fetchScope, FilterBySelectedMods.Value ? mods.Value.ToArray() : null, fetchSorting), forceRefresh: true); if (!initialFetchComplete) { // only bind this after the first fetch to avoid reading stale scores. fetchedScores.BindTo(leaderboardManager.Scores); - fetchedScores.BindValueChanged(_ => updateScores(), true); + + // Schedule is important here to avoid handling changes after this drawable is disposed. + fetchedScores.BindValueChanged(_ => Schedule(updateScores), true); initialFetchComplete = true; } - }, initialFetchComplete ? 300 : 0); + }, initialFetchComplete && fetchScope != BeatmapLeaderboardScope.Local ? 300 : 0); } private void updateScores() @@ -306,7 +310,12 @@ protected void SetScores(IEnumerable scores, ScoreInfo? userScore = n Rank = i + 1, Highlight = highlightType, SelectedMods = { BindTarget = mods }, - Action = () => onLeaderboardScoreClicked(s), + Action = songSelect?.CanPresentScore == true + ? () => songSelect.PresentScore(s) + : null, + ShowReplay = songSelect?.CanPresentScore == true + ? info => songSelect.PresentScore(info, ScorePresentType.Gameplay) + : null }; }), loadedScores => { @@ -366,9 +375,9 @@ protected void SetScores(IEnumerable scores, ScoreInfo? userScore = n scoresScroll.TransformTo(nameof(scoresScroll.Padding), new MarginPadding { Bottom = personal_best_height }, 300, Easing.OutQuint); if (totalCount != null && userScore.Position != null) - personalBestText.Text = $"Personal Best (#{userScore.Position:N0} of {totalCount.Value:N0})"; + personalBestText.Text = BeatmapLeaderboardWedgeStrings.PersonalBestWithPosition(userScore.Position.Value, totalCount.Value); else - personalBestText.Text = "Personal Best"; + personalBestText.Text = BeatmapLeaderboardWedgeStrings.PersonalBest; } } @@ -405,15 +414,27 @@ private void clearScores() private LeaderboardState displayedState; + private ScheduledDelegate? loadingShowDelegate; + protected void SetState(LeaderboardState state) { if (state == displayedState) return; if (state == LeaderboardState.Retrieving) - loading.Show(); + { + // Slight delay so this doesn't display for a few silly frames for local score retrievals. + loadingShowDelegate ??= Scheduler.AddDelayed(() => loading.Show(), 200); + } else + { + loadingShowDelegate?.Cancel(); + loadingShowDelegate = null; + loading.Hide(); + } + + loading.Hide(); displayedState = state; diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.FailRetryDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.FailRetryDisplay.cs similarity index 98% rename from osu.Game/Screens/SelectV2/BeatmapMetadataWedge.FailRetryDisplay.cs rename to osu.Game/Screens/Select/BeatmapMetadataWedge.FailRetryDisplay.cs index 9ee61b7c5ced..e47c35c3cef1 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.FailRetryDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.FailRetryDisplay.cs @@ -17,11 +17,11 @@ using osu.Game.Resources.Localisation.Web; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class FailRetryDisplay : CompositeDrawable + public partial class FailRetryDisplay : CompositeDrawable { private readonly GraphDrawable retriesGraph; private readonly GraphDrawable failsGraph; diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.MetadataDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.MetadataDisplay.cs similarity index 97% rename from osu.Game/Screens/SelectV2/BeatmapMetadataWedge.MetadataDisplay.cs rename to osu.Game/Screens/Select/BeatmapMetadataWedge.MetadataDisplay.cs index 1c3cf8f8eb7a..5aa8279f66a3 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.MetadataDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.MetadataDisplay.cs @@ -13,11 +13,11 @@ using osu.Game.Overlays; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class MetadataDisplay : FillFlowContainer + public partial class MetadataDisplay : FillFlowContainer { private readonly OsuSpriteText labelText; private readonly OsuSpriteText contentText; @@ -145,13 +145,13 @@ private void setText(LocalisableString text) contentText.Text = text; } - private void setLink(LocalisableString text, Action action) => Schedule(() => + private void setLink(LocalisableString text, Action action) { clear(); contentLinkText.Text = text; contentLink.Action = action; - }); + } private void setDate(DateTimeOffset date) { diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.RatingSpreadDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.RatingSpreadDisplay.cs similarity index 97% rename from osu.Game/Screens/SelectV2/BeatmapMetadataWedge.RatingSpreadDisplay.cs rename to osu.Game/Screens/Select/BeatmapMetadataWedge.RatingSpreadDisplay.cs index ee938ecdd9d9..a74ab3d8b6b7 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.RatingSpreadDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.RatingSpreadDisplay.cs @@ -13,11 +13,11 @@ using osu.Game.Resources.Localisation.Web; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class RatingSpreadDisplay : CompositeDrawable + public partial class RatingSpreadDisplay : CompositeDrawable { private const float min_height = 4f; private const float max_height = 32f; diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.SuccessRateDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.SuccessRateDisplay.cs similarity index 97% rename from osu.Game/Screens/SelectV2/BeatmapMetadataWedge.SuccessRateDisplay.cs rename to osu.Game/Screens/Select/BeatmapMetadataWedge.SuccessRateDisplay.cs index 611854727466..a7038ef9cbb4 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.SuccessRateDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.SuccessRateDisplay.cs @@ -15,11 +15,11 @@ using osu.Game.Resources.Localisation.Web; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class SuccessRateDisplay : CompositeDrawable, IHasTooltip + public partial class SuccessRateDisplay : CompositeDrawable, IHasTooltip { private readonly OsuSpriteText valueText; private readonly Circle backgroundBar; diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.TagsLine.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.TagsLine.cs similarity index 99% rename from osu.Game/Screens/SelectV2/BeatmapMetadataWedge.TagsLine.cs rename to osu.Game/Screens/Select/BeatmapMetadataWedge.TagsLine.cs index e48b4f20da9e..975dfd621254 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.TagsLine.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.TagsLine.cs @@ -22,7 +22,7 @@ using osu.Game.Overlays; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.UserRatingDisplay.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.UserRatingDisplay.cs similarity index 98% rename from osu.Game/Screens/SelectV2/BeatmapMetadataWedge.UserRatingDisplay.cs rename to osu.Game/Screens/Select/BeatmapMetadataWedge.UserRatingDisplay.cs index 2f38079577d1..8c55e5320c5b 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.UserRatingDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.UserRatingDisplay.cs @@ -13,11 +13,11 @@ using osu.Game.Resources.Localisation.Web; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge { - private partial class UserRatingDisplay : CompositeDrawable + public partial class UserRatingDisplay : CompositeDrawable { private readonly OsuSpriteText negativeText; private readonly OsuSpriteText positiveText; diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/Select/BeatmapMetadataWedge.cs similarity index 99% rename from osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs rename to osu.Game/Screens/Select/BeatmapMetadataWedge.cs index d516f4b8466e..128dde88c934 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/Select/BeatmapMetadataWedge.cs @@ -22,7 +22,7 @@ using osu.Game.Resources.Localisation.Web; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapMetadataWedge : VisibilityContainer { @@ -250,8 +250,8 @@ private void load(AudioManager audio) protected override void LoadComplete() { base.LoadComplete(); - beatmap.BindValueChanged(_ => updateDisplay()); - onlineLookupResult.BindValueChanged(_ => updateDisplay()); + beatmap.BindValueChanged(_ => Scheduler.AddOnce(updateDisplay)); + onlineLookupResult.BindValueChanged(_ => Scheduler.AddOnce(updateDisplay)); apiState = api.State.GetBoundCopy(); apiState.BindValueChanged(_ => Scheduler.AddOnce(updateDisplay), true); diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs b/osu.Game/Screens/Select/BeatmapTitleWedge.DifficultyDisplay.cs similarity index 93% rename from osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs rename to osu.Game/Screens/Select/BeatmapTitleWedge.DifficultyDisplay.cs index 55ed488d87e8..432ba22c31ef 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapTitleWedge.DifficultyDisplay.cs @@ -26,7 +26,7 @@ using osu.Game.Rulesets.Mods; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapTitleWedge { @@ -48,9 +48,6 @@ public partial class DifficultyDisplay : CompositeDrawable [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } = null!; - [Resolved] - private OsuColour colours { get; set; } = null!; - private StarRatingDisplay starRatingDisplay = null!; private FillFlowContainer nameLine = null!; private OsuSpriteText difficultyText = null!; @@ -209,8 +206,13 @@ protected override void LoadComplete() { base.LoadComplete(); - beatmap.BindValueChanged(_ => updateDisplay()); - ruleset.BindValueChanged(_ => updateDisplay()); + // it is not uncommon for the beatmap and the ruleset to change in conjunction during a single update frame. + // in that process, it is possible for the global bindable triad (beatmap / ruleset / mods) to briefly be partially invalid in combination (e.g. mods invalid for given ruleset). + // `updateDisplay()` will initiate a difficulty calculation, and if it is allowed to run in that invalid intermediate state, it will loudly fail. + // therefore, all changes that may initiate a difficulty calculation are debounced until the next frame to ensure the global bindable state is fully consistent - + // and it's what you'd want to do anyway for performance reasons. + beatmap.BindValueChanged(_ => Scheduler.AddOnce(updateDisplay)); + ruleset.BindValueChanged(_ => Scheduler.AddOnce(updateDisplay)); mods.BindValueChanged(m => { @@ -304,7 +306,7 @@ protected override void Update() difficultyText.MaxWidth = Math.Max(nameLine.DrawWidth - mappedByText.DrawWidth - mapperText.DrawWidth - 20, 0); // Use difficulty colour until it gets too dark to be visible against dark backgrounds. - Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : starRatingDisplay.DisplayedDifficultyColour; + Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? starRatingDisplay.DisplayedDifficultyTextColour : starRatingDisplay.DisplayedDifficultyColour; difficultyText.Colour = col; mappedByText.Colour = col; diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyStatisticsDisplay.cs b/osu.Game/Screens/Select/BeatmapTitleWedge.DifficultyStatisticsDisplay.cs similarity index 99% rename from osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyStatisticsDisplay.cs rename to osu.Game/Screens/Select/BeatmapTitleWedge.DifficultyStatisticsDisplay.cs index 595959cfce7d..e07eea1491d3 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyStatisticsDisplay.cs +++ b/osu.Game/Screens/Select/BeatmapTitleWedge.DifficultyStatisticsDisplay.cs @@ -17,7 +17,7 @@ using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapTitleWedge { diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.FavouriteButton.cs b/osu.Game/Screens/Select/BeatmapTitleWedge.FavouriteButton.cs similarity index 99% rename from osu.Game/Screens/SelectV2/BeatmapTitleWedge.FavouriteButton.cs rename to osu.Game/Screens/Select/BeatmapTitleWedge.FavouriteButton.cs index 62ac8a07b4dd..bd3aaaa2396b 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.FavouriteButton.cs +++ b/osu.Game/Screens/Select/BeatmapTitleWedge.FavouriteButton.cs @@ -26,7 +26,7 @@ using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapTitleWedge { diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.Statistic.cs b/osu.Game/Screens/Select/BeatmapTitleWedge.Statistic.cs similarity index 99% rename from osu.Game/Screens/SelectV2/BeatmapTitleWedge.Statistic.cs rename to osu.Game/Screens/Select/BeatmapTitleWedge.Statistic.cs index 85a0382360a6..1ed08ce124ce 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.Statistic.cs +++ b/osu.Game/Screens/Select/BeatmapTitleWedge.Statistic.cs @@ -15,7 +15,7 @@ using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapTitleWedge { diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticDifficulty.cs b/osu.Game/Screens/Select/BeatmapTitleWedge.StatisticDifficulty.cs similarity index 99% rename from osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticDifficulty.cs rename to osu.Game/Screens/Select/BeatmapTitleWedge.StatisticDifficulty.cs index bcce78246d96..3133f8a67bcb 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticDifficulty.cs +++ b/osu.Game/Screens/Select/BeatmapTitleWedge.StatisticDifficulty.cs @@ -19,7 +19,7 @@ using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapTitleWedge { diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticPlayCount.cs b/osu.Game/Screens/Select/BeatmapTitleWedge.StatisticPlayCount.cs similarity index 99% rename from osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticPlayCount.cs rename to osu.Game/Screens/Select/BeatmapTitleWedge.StatisticPlayCount.cs index d193cbe286eb..f8f5fe378231 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticPlayCount.cs +++ b/osu.Game/Screens/Select/BeatmapTitleWedge.StatisticPlayCount.cs @@ -18,7 +18,7 @@ using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapTitleWedge { diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/Select/BeatmapTitleWedge.cs similarity index 98% rename from osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs rename to osu.Game/Screens/Select/BeatmapTitleWedge.cs index a74872eaa709..657e3ba15b35 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/Select/BeatmapTitleWedge.cs @@ -20,6 +20,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; @@ -27,7 +28,7 @@ using osu.Game.Utils; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class BeatmapTitleWedge : VisibilityContainer { @@ -278,7 +279,7 @@ private void updateLengthAndBpmStatistics() bpmStatistic.Text = bpmMin == bpmMax ? $"{bpmMin}" - : $"{bpmMin}-{bpmMax} (mostly {mostCommonBPM})"; + : LocalisableString.Interpolate($"{bpmMin}-{bpmMax} ({SongSelectStrings.MostlyBPM(mostCommonBPM)})"); }); }, token); } diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs deleted file mode 100644 index 39bf4e134baf..000000000000 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using osu.Game.Beatmaps; -using osu.Game.Screens.Select.Filter; -using osu.Game.Utils; - -namespace osu.Game.Screens.Select.Carousel -{ - public class CarouselBeatmap : CarouselItem - { - public override float TotalHeight => DrawableCarouselBeatmap.HEIGHT; - - public readonly BeatmapInfo BeatmapInfo; - - public CarouselBeatmap(BeatmapInfo beatmapInfo) - { - BeatmapInfo = beatmapInfo; - State.Value = CarouselItemState.Collapsed; - } - - public override DrawableCarouselItem CreateDrawableRepresentation() => new DrawableCarouselBeatmap(this); - - public override void Filter(FilterCriteria criteria) - { - base.Filter(criteria); - - Filtered.Value = !checkMatch(criteria); - } - - private bool checkMatch(FilterCriteria criteria) - { - bool match = - criteria.Ruleset == null || - BeatmapInfo.Ruleset.ShortName == criteria.Ruleset.ShortName || - (BeatmapInfo.Ruleset.OnlineID == 0 && criteria.Ruleset.OnlineID != 0 && criteria.AllowConvertedBeatmaps); - - if (BeatmapInfo.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) - { - // only check ruleset equality or convertability for selected beatmap - return match; - } - - if (!match) return false; - - if (criteria.SearchTerms.Length > 0) - { - match = BeatmapInfo.Match(criteria.SearchTerms); - - // if a match wasn't found via text matching of terms, do a second catch-all check matching against online IDs. - // this should be done after text matching so we can prioritise matching numbers in metadata. - if (!match && criteria.SearchNumber.HasValue) - { - match = (BeatmapInfo.OnlineID == criteria.SearchNumber.Value) || - (BeatmapInfo.BeatmapSet?.OnlineID == criteria.SearchNumber.Value); - } - } - - if (!match) return false; - - match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(BeatmapInfo.StarRating.FloorToDecimalDigits(2)); - match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(BeatmapInfo.Difficulty.ApproachRate); - match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(BeatmapInfo.Difficulty.DrainRate); - match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(BeatmapInfo.Difficulty.CircleSize); - match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(BeatmapInfo.Difficulty.OverallDifficulty); - match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(BeatmapInfo.Length); - match &= !criteria.LastPlayed.HasFilter || criteria.LastPlayed.IsInRange(BeatmapInfo.LastPlayed ?? DateTimeOffset.MinValue); - match &= !criteria.DateRanked.HasFilter || (BeatmapInfo.BeatmapSet?.DateRanked != null && criteria.DateRanked.IsInRange(BeatmapInfo.BeatmapSet.DateRanked.Value)); - match &= !criteria.DateSubmitted.HasFilter || (BeatmapInfo.BeatmapSet?.DateSubmitted != null && criteria.DateSubmitted.IsInRange(BeatmapInfo.BeatmapSet.DateSubmitted.Value)); - match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(BeatmapInfo.BPM); - - match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(BeatmapInfo.BeatDivisor); - match &= !criteria.OnlineStatus.HasFilter || criteria.OnlineStatus.IsInRange(BeatmapInfo.Status); - - if (!match) return false; - - match &= !criteria.Creator.HasFilter || criteria.Creator.Matches(BeatmapInfo.Metadata.Author.Username); - - if (criteria.Artist.HasFilter) - { - if (criteria.Artist.ExcludeTerm) - match &= criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) && criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); - else - match &= criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) || criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); - } - - if (criteria.Title.HasFilter) - { - if (criteria.Title.ExcludeTerm) - match &= criteria.Title.Matches(BeatmapInfo.Metadata.Title) && criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode); - else - match &= criteria.Title.Matches(BeatmapInfo.Metadata.Title) || criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode); - } - - match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName); - match &= !criteria.Source.HasFilter || criteria.Source.Matches(BeatmapInfo.Metadata.Source); - - if (criteria.UserTags.Any()) - { - foreach (var tagFilter in criteria.UserTags) - { - if (tagFilter.ExcludeTerm) - { - // if `ExcludeTerm` is true, `Matches()` will return true if a user tag *doesn't match* the excluded term. - // thus, every user tag must pass this filter. - foreach (string tag in BeatmapInfo.Metadata.UserTags) - match &= tagFilter.Matches(tag); - } - else - { - // if `ExcludeTerm` is false, `Matches()` will return true if a user tag *matches* the expected term. - // the expected behaviour is that a beatmap should be displayed if at least one of the user tags passes the filter. - bool anyTagMatched = false; - - foreach (string tag in BeatmapInfo.Metadata.UserTags) - anyTagMatched |= tagFilter.Matches(tag); - - match &= anyTagMatched; - } - } - } - - match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating); - - if (!match) return false; - - match &= criteria.CollectionBeatmapMD5Hashes?.Contains(BeatmapInfo.MD5Hash) ?? true; - if (match && criteria.RulesetCriteria != null) - match &= criteria.RulesetCriteria.Matches(BeatmapInfo, criteria); - - if (match && criteria.HasOnlineID == true) - match &= BeatmapInfo.OnlineID >= 0; - - if (match && criteria.BeatmapSetId != null) - match &= criteria.BeatmapSetId == BeatmapInfo.BeatmapSet?.OnlineID; - - return match; - } - - public override int CompareTo(FilterCriteria criteria, CarouselItem other) - { - if (!(other is CarouselBeatmap otherBeatmap)) - return base.CompareTo(criteria, other); - - switch (criteria.Sort) - { - default: - case SortMode.Difficulty: - int ruleset = BeatmapInfo.Ruleset.CompareTo(otherBeatmap.BeatmapInfo.Ruleset); - - if (ruleset != 0) return ruleset; - - return BeatmapInfo.StarRating.CompareTo(otherBeatmap.BeatmapInfo.StarRating); - } - } - - public override string ToString() => BeatmapInfo.ToString(); - } -} diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs deleted file mode 100644 index 7e1569980438..000000000000 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Game.Beatmaps; -using osu.Game.Screens.Select.Filter; -using osu.Game.Utils; - -namespace osu.Game.Screens.Select.Carousel -{ - public class CarouselBeatmapSet : CarouselGroupEagerSelect - { - public override float TotalHeight - { - get - { - switch (State.Value) - { - case CarouselItemState.Selected: - return DrawableCarouselBeatmapSet.HEIGHT + Items.Count(c => c.Visible) * DrawableCarouselBeatmap.HEIGHT; - - default: - return DrawableCarouselBeatmapSet.HEIGHT; - } - } - } - - public IEnumerable Beatmaps => Items.OfType(); - - public BeatmapSetInfo BeatmapSet; - - public Func, BeatmapInfo?>? GetRecommendedBeatmap; - - public CarouselBeatmapSet(BeatmapSetInfo beatmapSet) - { - BeatmapSet = beatmapSet ?? throw new ArgumentNullException(nameof(beatmapSet)); - - beatmapSet.Beatmaps - .Where(b => !b.Hidden) - .OrderBy(b => b.Ruleset) - .ThenBy(b => b.StarRating) - .Select(b => new CarouselBeatmap(b)) - .ForEach(AddItem); - } - - public override CarouselItem? GetNextToSelect() - { - if (LastSelected == null || LastSelected.Filtered.Value) - { - if (GetRecommendedBeatmap?.Invoke(Items.OfType().Where(b => !b.Filtered.Value).Select(b => b.BeatmapInfo)) is BeatmapInfo recommended) - return Items.OfType().First(b => b.BeatmapInfo.Equals(recommended)); - } - - return base.GetNextToSelect(); - } - - public override int CompareTo(FilterCriteria criteria, CarouselItem other) - { - if (!(other is CarouselBeatmapSet otherSet)) - return base.CompareTo(criteria, other); - - int comparison; - - switch (criteria.Sort) - { - default: - case SortMode.Artist: - comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(BeatmapSet.Metadata.Artist, otherSet.BeatmapSet.Metadata.Artist); - if (comparison == 0) - goto case SortMode.Title; - break; - - case SortMode.Title: - comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(BeatmapSet.Metadata.Title, otherSet.BeatmapSet.Metadata.Title); - break; - - case SortMode.Author: - comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(BeatmapSet.Metadata.Author.Username, otherSet.BeatmapSet.Metadata.Author.Username); - break; - - case SortMode.Source: - comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(BeatmapSet.Metadata.Source, otherSet.BeatmapSet.Metadata.Source); - break; - - case SortMode.DateAdded: - comparison = otherSet.BeatmapSet.DateAdded.CompareTo(BeatmapSet.DateAdded); - break; - - case SortMode.DateRanked: - comparison = Nullable.Compare(otherSet.BeatmapSet.DateRanked, BeatmapSet.DateRanked); - break; - - case SortMode.LastPlayed: - comparison = -compareUsingAggregateMax(otherSet, static b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds()); - break; - - case SortMode.BPM: - comparison = compareUsingAggregateMax(otherSet, static b => b.BPM); - break; - - case SortMode.Length: - comparison = compareUsingAggregateMax(otherSet, static b => b.Length); - break; - - case SortMode.Difficulty: - comparison = compareUsingAggregateMax(otherSet, static b => b.StarRating); - break; - - case SortMode.DateSubmitted: - comparison = Nullable.Compare(otherSet.BeatmapSet.DateSubmitted, BeatmapSet.DateSubmitted); - break; - } - - if (comparison != 0) return comparison; - - // If the initial sort could not differentiate, attempt to use DateAdded to order sets in a stable fashion. - // The directionality of this matches the current SortMode.DateAdded, but we may want to reconsider if that becomes a user decision (ie. asc / desc). - comparison = otherSet.BeatmapSet.DateAdded.CompareTo(BeatmapSet.DateAdded); - - if (comparison != 0) return comparison; - - // If DateAdded fails to break the tie, fallback to our internal GUID for stability. - // This basically means it's a stable random sort. - return otherSet.BeatmapSet.ID.CompareTo(BeatmapSet.ID); - } - - /// - /// All beatmaps which are not filtered and valid for display. - /// - protected IEnumerable ValidBeatmaps - { - get - { - foreach (var item in Items) // iterating over Items directly to not allocate 2 enumerators - { - if (item is CarouselBeatmap b && (!b.Filtered.Value || b.State.Value == CarouselItemState.Selected)) - yield return b.BeatmapInfo; - } - } - } - - /// - /// Whether there are available beatmaps which are not filtered and valid for display. - /// Cheaper alternative to .Any() - /// - public bool HasValidBeatmaps - { - get - { - foreach (var item in Items) // iterating over Items directly to not allocate 2 enumerators - { - if (item is CarouselBeatmap b && (!b.Filtered.Value || b.State.Value == CarouselItemState.Selected)) - return true; - } - - return false; - } - } - - private int compareUsingAggregateMax(CarouselBeatmapSet other, Func func) - { - bool ourBeatmaps = HasValidBeatmaps; - bool otherBeatmaps = other.HasValidBeatmaps; - - if (!ourBeatmaps && !otherBeatmaps) return 0; - if (!ourBeatmaps) return -1; - if (!otherBeatmaps) return 1; - - return ValidBeatmaps.Max(func).CompareTo(other.ValidBeatmaps.Max(func)); - } - - public override void Filter(FilterCriteria criteria) - { - base.Filter(criteria); - - Filtered.Value = Items.All(i => i.Filtered.Value); - } - - public override string ToString() => BeatmapSet.ToString(); - } -} diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs deleted file mode 100644 index c0fb5fa3977d..000000000000 --- a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Framework.Extensions.ListExtensions; -using osu.Framework.Lists; - -namespace osu.Game.Screens.Select.Carousel -{ - /// - /// A group which ensures only one item is selected. - /// - public abstract class CarouselGroup : CarouselItem - { - protected CarouselGroup(List? items = null) - { - if (items != null) this.items = items; - - State.ValueChanged += state => - { - switch (state.NewValue) - { - case CarouselItemState.Collapsed: - case CarouselItemState.NotSelected: - this.items.ForEach(c => c.State.Value = CarouselItemState.Collapsed); - break; - - case CarouselItemState.Selected: - this.items.ForEach(c => - { - if (c.State.Value == CarouselItemState.Collapsed) c.State.Value = CarouselItemState.NotSelected; - }); - break; - } - }; - } - - public override DrawableCarouselItem? CreateDrawableRepresentation() => null; - - public SlimReadOnlyListWrapper Items => items.AsSlimReadOnly(); - - public int TotalItemsNotFiltered { get; private set; } - - private readonly List items = new List(); - - /// - /// Used to assign a monotonically increasing ID to items as they are added. This member is - /// incremented whenever an item is added. - /// - private ulong currentItemID; - - private Comparer? criteriaComparer; - private FilterCriteria? lastCriteria; - - protected int GetIndexOfItem(CarouselItem lastSelected) => items.IndexOf(lastSelected); - - public virtual void RemoveItem(CarouselItem i) - { - items.Remove(i); - - if (!i.Filtered.Value) - TotalItemsNotFiltered--; - - // it's important we do the deselection after removing, so any further actions based on - // State.ValueChanged make decisions post-removal. - i.State.Value = CarouselItemState.Collapsed; - } - - public virtual void AddItem(CarouselItem i) - { - i.State.ValueChanged += state => ChildItemStateChanged(i, state.NewValue); - i.ItemID = ++currentItemID; - - if (lastCriteria != null) - { - i.Filter(lastCriteria); - - int index = items.BinarySearch(i, criteriaComparer); - if (index < 0) index = ~index; // BinarySearch hacks multiple return values with 2's complement. - - items.Insert(index, i); - } - else - { - // criteria may be null for initial population. the filtering will be applied post-add. - items.Add(i); - } - - if (!i.Filtered.Value) - TotalItemsNotFiltered++; - } - - public override void Filter(FilterCriteria criteria) - { - base.Filter(criteria); - - TotalItemsNotFiltered = 0; - - foreach (var c in items) - { - c.Filter(criteria); - if (!c.Filtered.Value) - TotalItemsNotFiltered++; - } - - // Sorting is expensive, so only perform if it's actually changed. - if (lastCriteria?.RequiresSorting(criteria) != false) - { - criteriaComparer = Comparer.Create((x, y) => - { - int comparison = x.CompareTo(criteria, y); - if (comparison != 0) - return comparison; - - return x.ItemID.CompareTo(y.ItemID); - }); - - items.Sort(criteriaComparer); - } - - lastCriteria = criteria; - } - - protected virtual void ChildItemStateChanged(CarouselItem item, CarouselItemState value) - { - // ensure we are the only item selected - if (value == CarouselItemState.Selected) - { - foreach (var b in items) - { - if (item == b) continue; - - b.State.Value = CarouselItemState.NotSelected; - } - - State.Value = CarouselItemState.Selected; - } - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs deleted file mode 100644 index 8cc1ea258a71..000000000000 --- a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace osu.Game.Screens.Select.Carousel -{ - /// - /// A group which ensures at least one item is selected (if the group itself is selected). - /// - public abstract class CarouselGroupEagerSelect : CarouselGroup - { - protected CarouselGroupEagerSelect() - { - State.ValueChanged += state => - { - if (state.NewValue == CarouselItemState.Selected) - attemptSelection(); - }; - } - - /// - /// The last selected item. - /// - protected CarouselItem? LastSelected { get; private set; } - - /// - /// We need to keep track of the index for cases where the selection is removed but we want to select a new item based on its old location. - /// - private int lastSelectedIndex; - - /// - /// To avoid overhead during filter operations, we don't attempt any selections until after all - /// items have been filtered. This bool will be true during the base - /// operation. - /// - protected bool DisableSelection; - - public override void Filter(FilterCriteria criteria) - { - DisableSelection = true; - base.Filter(criteria); - DisableSelection = false; - - attemptSelection(); - } - - public override void RemoveItem(CarouselItem i) - { - base.RemoveItem(i); - - if (i != LastSelected) - updateSelectedIndex(); - } - - private bool addingItems; - - public void AddItems(IEnumerable items) - { - addingItems = true; - - foreach (var i in items) - AddItem(i); - - addingItems = false; - - attemptSelection(); - } - - public override void AddItem(CarouselItem i) - { - base.AddItem(i); - if (!addingItems) - attemptSelection(); - } - - protected override void ChildItemStateChanged(CarouselItem item, CarouselItemState value) - { - base.ChildItemStateChanged(item, value); - - switch (value) - { - case CarouselItemState.Selected: - updateSelected(item); - break; - - case CarouselItemState.NotSelected: - case CarouselItemState.Collapsed: - attemptSelection(); - break; - } - } - - private void attemptSelection() - { - if (DisableSelection) return; - - // we only perform eager selection if we are a currently selected group. - if (State.Value != CarouselItemState.Selected) return; - - // we only perform eager selection if none of our items are in a selected state already. - if (Items.Any(i => i.State.Value == CarouselItemState.Selected)) return; - - PerformSelection(); - } - - /// - /// Finds the item this group would select next if it attempted selection - /// - /// An unfiltered item nearest to the last selected one or null if all items are filtered - public virtual CarouselItem? GetNextToSelect() - { - if (Items.Count == 0) - return null; - - int forwardsIndex = lastSelectedIndex; - int backwardsIndex = Math.Min(lastSelectedIndex, Items.Count - 1); - - while (true) - { - bool hasBackwards = backwardsIndex >= 0 && backwardsIndex < Items.Count; - bool hasForwards = forwardsIndex < Items.Count; - - if (!hasBackwards && !hasForwards) - return null; - - if (hasForwards && !Items[forwardsIndex].Filtered.Value) - return Items[forwardsIndex]; - - if (hasBackwards && !Items[backwardsIndex].Filtered.Value) - return Items[backwardsIndex]; - - forwardsIndex++; - backwardsIndex--; - } - } - - protected virtual void PerformSelection() - { - CarouselItem? nextToSelect = GetNextToSelect(); - - if (nextToSelect != null) - nextToSelect.State.Value = CarouselItemState.Selected; - else - updateSelected(null); - } - - private void updateSelected(CarouselItem? newSelection) - { - if (newSelection != null) - LastSelected = newSelection; - updateSelectedIndex(); - } - - private void updateSelectedIndex() => lastSelectedIndex = LastSelected == null ? 0 : Math.Max(0, GetIndexOfItem(LastSelected)); - } -} diff --git a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs deleted file mode 100644 index 7e668fcd879b..000000000000 --- a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Framework.Utils; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Select.Carousel -{ - public partial class CarouselHeader : Container - { - public Container BorderContainer; - - public readonly Bindable State = new Bindable(CarouselItemState.NotSelected); - - private readonly HoverLayer hoverLayer; - - protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; - - private const float corner_radius = 10; - private const float border_thickness = 2.5f; - - public CarouselHeader() - { - RelativeSizeAxes = Axes.X; - Height = DrawableCarouselItem.MAX_HEIGHT; - - InternalChild = BorderContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = corner_radius, - BorderColour = new Color4(221, 255, 255, 255), - Children = new Drawable[] - { - Content, - hoverLayer = new HoverLayer(), - new HeaderSounds(), - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - State.BindValueChanged(updateState, true); - } - - private void updateState(ValueChangedEvent state) - { - switch (state.NewValue) - { - case CarouselItemState.Collapsed: - case CarouselItemState.NotSelected: - hoverLayer.InsetForBorder = false; - - BorderContainer.BorderThickness = 0; - BorderContainer.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Offset = new Vector2(1), - Radius = 10, - Colour = Color4.Black.Opacity(100), - }; - break; - - case CarouselItemState.Selected: - hoverLayer.InsetForBorder = true; - - BorderContainer.BorderThickness = border_thickness; - BorderContainer.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = new Color4(130, 204, 255, 150), - Radius = 20, - Roundness = 10, - }; - break; - } - } - - public partial class HoverLayer : CompositeDrawable - { - private Box box = null!; - - public HoverLayer() - { - RelativeSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - InternalChild = box = new Box - { - Colour = colours.Blue.Opacity(0.1f), - Alpha = 0, - Blending = BlendingParameters.Additive, - RelativeSizeAxes = Axes.Both, - }; - } - - public bool InsetForBorder - { - set - { - if (value) - { - // apply same border as above to avoid applying additive overlay to it (and blowing out the colour). - Masking = true; - CornerRadius = corner_radius; - BorderThickness = border_thickness; - } - else - { - BorderThickness = 0; - CornerRadius = 0; - Masking = false; - } - } - } - - protected override bool OnHover(HoverEvent e) - { - box.FadeIn(100, Easing.OutQuint); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - box.FadeOut(1000, Easing.OutQuint); - base.OnHoverLost(e); - } - } - - private partial class HeaderSounds : HoverSampleDebounceComponent - { - private Sample? sampleHover; - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleHover = audio.Samples.Get("UI/default-hover"); - } - - public override void PlayHoverSample() - { - if (sampleHover == null) return; - - sampleHover.Frequency.Value = 0.99 + RNG.NextDouble(0.02); - sampleHover.Play(); - } - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/CarouselItem.cs b/osu.Game/Screens/Select/Carousel/CarouselItem.cs deleted file mode 100644 index 5e425a4a1c0b..000000000000 --- a/osu.Game/Screens/Select/Carousel/CarouselItem.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Bindables; - -namespace osu.Game.Screens.Select.Carousel -{ - public abstract class CarouselItem : IComparable - { - public virtual float TotalHeight => 0; - - /// - /// An externally defined value used to determine this item's vertical display offset relative to the carousel. - /// - public float CarouselYPosition; - - public readonly BindableBool Filtered = new BindableBool(); - - public readonly Bindable State = new Bindable(CarouselItemState.NotSelected); - - /// - /// This item is not in a hidden state. - /// - public bool Visible => State.Value != CarouselItemState.Collapsed && !Filtered.Value; - - protected CarouselItem() - { - Filtered.ValueChanged += filtered => - { - if (filtered.NewValue && State.Value == CarouselItemState.Selected) - State.Value = CarouselItemState.NotSelected; - }; - } - - /// - /// Used as a default sort method for s of differing types. - /// - internal ulong ItemID; - - /// - /// Create a fresh drawable version of this item. - /// - public abstract DrawableCarouselItem? CreateDrawableRepresentation(); - - public virtual void Filter(FilterCriteria criteria) - { - } - - public virtual int CompareTo(FilterCriteria criteria, CarouselItem other) => ItemID.CompareTo(other.ItemID); - - public int CompareTo(CarouselItem? other) - { - if (other == null) return 1; - - return CarouselYPosition.CompareTo(other.CarouselYPosition); - } - } - - public enum CarouselItemState - { - Collapsed, - NotSelected, - Selected, - } -} diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs deleted file mode 100644 index a8f5b6dd24b8..000000000000 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ /dev/null @@ -1,321 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Collections; -using osu.Game.Database; -using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; -using osu.Game.Overlays; -using osu.Game.Resources.Localisation.Web; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osuTK; -using osuTK.Graphics; -using CommonStrings = osu.Game.Localisation.CommonStrings; -using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; - -namespace osu.Game.Screens.Select.Carousel -{ - public partial class DrawableCarouselBeatmap : DrawableCarouselItem, IHasContextMenu - { - public const float CAROUSEL_BEATMAP_SPACING = 5; - - /// - /// The height of a carousel beatmap, including vertical spacing. - /// - public const float HEIGHT = height + CAROUSEL_BEATMAP_SPACING; - - private const float height = MAX_HEIGHT * 0.6f; - - private readonly BeatmapInfo beatmapInfo; - - private Sprite background = null!; - - private MenuItem[]? mainMenuItems; - - private Action? selectRequested; - private Action? hideRequested; - - private Triangles triangles = null!; - - private StarCounter starCounter = null!; - private DifficultyIcon difficultyIcon = null!; - - private OsuSpriteText keyCountText = null!; - - [Resolved] - private BeatmapSetOverlay? beatmapOverlay { get; set; } - - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } = null!; - - [Resolved] - private ManageCollectionsDialog? manageCollectionsDialog { get; set; } - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - [Resolved] - private IBindable ruleset { get; set; } = null!; - - [Resolved] - private IBindable> mods { get; set; } = null!; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - [Resolved] - private OsuGame? game { get; set; } - - [Resolved] - private BeatmapManager? manager { get; set; } - - private IBindable starDifficultyBindable = null!; - private CancellationTokenSource? starDifficultyCancellationSource; - - public DrawableCarouselBeatmap(CarouselBeatmap panel) - { - beatmapInfo = panel.BeatmapInfo; - Item = panel; - } - - [BackgroundDependencyLoader] - private void load(SongSelect? songSelect) - { - Header.Height = height; - - if (songSelect != null) - { - mainMenuItems = songSelect.CreateForwardNavigationMenuItemsForBeatmap(() => beatmapInfo); - selectRequested = b => songSelect.FinaliseSelection(b); - } - - if (manager != null) - hideRequested = b => manager.Hide(b); - - Header.Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - }, - triangles = new Triangles - { - TriangleScale = 2, - RelativeSizeAxes = Axes.Both, - ColourLight = Color4Extensions.FromHex(@"3a7285"), - ColourDark = Color4Extensions.FromHex(@"123744") - }, - new FillFlowContainer - { - Padding = new MarginPadding(5), - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Children = new Drawable[] - { - difficultyIcon = new DifficultyIcon(beatmapInfo) - { - TooltipType = DifficultyIconTooltipType.None, - Scale = new Vector2(1.8f), - }, - new FillFlowContainer - { - Padding = new MarginPadding { Left = 5 }, - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - Spacing = new Vector2(4, 0), - AutoSizeAxes = Axes.Both, - Children = new[] - { - keyCountText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 20), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Alpha = 0, - }, - new OsuSpriteText - { - Text = beatmapInfo.DifficultyName, - Font = OsuFont.GetFont(size: 20), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft - }, - new OsuSpriteText - { - Text = BeatmapsetsStrings.ShowDetailsMappedBy(beatmapInfo.Metadata.Author.Username), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft - }, - } - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - Spacing = new Vector2(4, 0), - Scale = new Vector2(0.8f), - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new TopLocalRank(beatmapInfo), - starCounter = new StarCounter() - } - } - } - } - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - ruleset.BindValueChanged(_ => updateKeyCount()); - mods.BindValueChanged(_ => updateKeyCount()); - } - - protected override void Selected() - { - base.Selected(); - - MovementContainer.MoveToX(-50, 500, Easing.OutExpo); - - background.Colour = ColourInfo.GradientVertical( - new Color4(20, 43, 51, 255), - new Color4(40, 86, 102, 255)); - - triangles.Colour = Color4.White; - } - - protected override void Deselected() - { - base.Deselected(); - - MovementContainer.MoveToX(0, 500, Easing.OutExpo); - - background.Colour = new Color4(20, 43, 51, 255); - triangles.Colour = OsuColour.Gray(0.5f); - } - - protected override bool OnClick(ClickEvent e) - { - if (Item?.State.Value == CarouselItemState.Selected) - selectRequested?.Invoke(beatmapInfo); - - return base.OnClick(e); - } - - protected override void ApplyState() - { - if (Item?.State.Value != CarouselItemState.Collapsed && Alpha == 0) - starCounter.ReplayAnimation(); - - starDifficultyCancellationSource?.Cancel(); - - // Only compute difficulty when the item is visible. - if (Item?.State.Value != CarouselItemState.Collapsed) - { - // We've potentially cancelled the computation above so a new bindable is required. - starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmapInfo, (starDifficultyCancellationSource = new CancellationTokenSource()).Token, 200); - starDifficultyBindable.BindValueChanged(d => - { - starCounter.Current = (float)(d.NewValue.Stars); - difficultyIcon.Current.Value = d.NewValue; - }, true); - - updateKeyCount(); - } - - base.ApplyState(); - } - - private void updateKeyCount() - { - if (Item?.State.Value == CarouselItemState.Collapsed) - return; - - if (ruleset.Value.OnlineID == 3) - { - // Account for mania differences locally for now. - // Eventually this should be handled in a more modular way, allowing rulesets to add more information to the panel. - ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); - - keyCountText.Alpha = 1; - keyCountText.Text = $"[{legacyRuleset.GetKeyCount(beatmapInfo, mods.Value)}K]"; - } - else - keyCountText.Alpha = 0; - } - - public MenuItem[] ContextMenuItems - { - get - { - List items = new List(); - - if (mainMenuItems != null) - items.AddRange(mainMenuItems); - - if (beatmapInfo.OnlineID > 0 && beatmapOverlay != null) - items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmapInfo.OnlineID))); - - var collectionItems = realm.Realm.All() - .OrderBy(c => c.Name) - .AsEnumerable() - .Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmapInfo)).Cast().ToList(); - - if (manageCollectionsDialog != null) - collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); - - items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); - - if (beatmapInfo.GetOnlineURL(api, ruleset.Value) is string url) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard(url))); - - if (manager != null) - items.Add(new OsuMenuItem("Mark as played", MenuItemType.Standard, () => manager.MarkPlayed(beatmapInfo))); - - if (hideRequested != null) - items.Add(new OsuMenuItem(WebCommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => hideRequested(beatmapInfo))); - - return items.ToArray(); - } - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - starDifficultyCancellationSource?.Cancel(); - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs deleted file mode 100644 index c410cb7d69af..000000000000 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ /dev/null @@ -1,356 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Primitives; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Utils; -using osu.Game.Beatmaps; -using osu.Game.Collections; -using osu.Game.Database; -using osu.Game.Graphics.UserInterface; -using osu.Game.Localisation; -using osu.Game.Online.API; -using osu.Game.Overlays; -using osu.Game.Rulesets; - -namespace osu.Game.Screens.Select.Carousel -{ - public partial class DrawableCarouselBeatmapSet : DrawableCarouselItem, IHasContextMenu - { - public const float HEIGHT = MAX_HEIGHT; - - private Action restoreHiddenRequested = null!; - private Action? viewDetails; - - [Resolved] - private IDialogOverlay? dialogOverlay { get; set; } - - [Resolved] - private ManageCollectionsDialog? manageCollectionsDialog { get; set; } - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - [Resolved] - private OsuGame? game { get; set; } - - [Resolved] - private IBindable ruleset { get; set; } = null!; - - public IReadOnlyList DrawableBeatmaps => beatmapContainer?.IsLoaded != true ? Array.Empty() : beatmapContainer; - - private Container? beatmapContainer; - - private BeatmapSetInfo beatmapSet = null!; - - private Task? beatmapsLoadTask; - - private MenuItem[]? mainMenuItems; - - private double timeSinceUnpool; - - [Resolved] - private BeatmapManager manager { get; set; } = null!; - - protected override void FreeAfterUse() - { - base.FreeAfterUse(); - - Item = null; - timeSinceUnpool = 0; - - ClearTransforms(); - } - - [BackgroundDependencyLoader] - private void load(BeatmapSetOverlay? beatmapOverlay, SongSelect? songSelect) - { - if (songSelect != null) - mainMenuItems = songSelect.CreateForwardNavigationMenuItemsForBeatmap(() => (((CarouselBeatmapSet)Item!).GetNextToSelect() as CarouselBeatmap)!.BeatmapInfo); - - restoreHiddenRequested = s => - { - foreach (var b in s.Beatmaps) - manager.Restore(b); - }; - - if (beatmapOverlay != null) - viewDetails = beatmapOverlay.FetchAndShowBeatmapSet; - } - - protected override void Update() - { - base.Update(); - - Debug.Assert(Item != null); - - // position updates should not occur if the item is filtered away. - // this avoids panels flying across the screen only to be eventually off-screen or faded out. - if (!Item.Visible) return; - - float targetY = Item.CarouselYPosition; - - if (Precision.AlmostEquals(targetY, Y)) - Y = targetY; - else - // algorithm for this is taken from ScrollContainer. - // while it doesn't necessarily need to match 1:1, as we are emulating scroll in some cases this feels most correct. - Y = (float)Interpolation.Lerp(targetY, Y, Math.Exp(-0.01 * Time.Elapsed)); - - loadContentIfRequired(); - } - - private CancellationTokenSource? loadCancellation; - - protected override void UpdateItem() - { - loadCancellation?.Cancel(); - loadCancellation = null; - - base.UpdateItem(); - - Content.Clear(); - Header.Clear(); - - beatmapContainer = null; - beatmapsLoadTask = null; - - if (Item == null) - return; - - beatmapSet = ((CarouselBeatmapSet)Item).BeatmapSet; - } - - protected override void Deselected() - { - base.Deselected(); - - MovementContainer.MoveToX(0, 500, Easing.OutExpo); - - updateBeatmapYPositions(); - } - - protected override void Selected() - { - base.Selected(); - - MovementContainer.MoveToX(-100, 500, Easing.OutExpo); - - updateBeatmapDifficulties(); - } - - private void updateBeatmapDifficulties() - { - Debug.Assert(Item != null); - - var carouselBeatmapSet = (CarouselBeatmapSet)Item; - - var visibleBeatmaps = carouselBeatmapSet.Items.Where(c => c.Visible).ToArray(); - - // if we are already displaying all the correct beatmaps, only run animation updates. - // note that the displayed beatmaps may change due to the applied filter. - // a future optimisation could add/remove only changed difficulties rather than reinitialise. - if (beatmapContainer != null && visibleBeatmaps.Length == beatmapContainer.Count && visibleBeatmaps.All(b => beatmapContainer.Any(c => c.Item == b))) - { - updateBeatmapYPositions(); - } - else - { - // on selection we show our child beatmaps. - // for now this is a simple drawable construction each selection. - // can be improved in the future. - beatmapContainer = new Container - { - X = 100, - RelativeSizeAxes = Axes.Both, - ChildrenEnumerable = visibleBeatmaps.Select(c => c.CreateDrawableRepresentation()!) - }; - - beatmapsLoadTask = LoadComponentAsync(beatmapContainer, loaded => - { - // make sure the pooled target hasn't changed. - if (beatmapContainer != loaded) - return; - - Content.Child = loaded; - updateBeatmapYPositions(); - }); - } - } - - [Resolved] - private BeatmapCarousel.CarouselScrollContainer scrollContainer { get; set; } = null!; - - private void loadContentIfRequired() - { - Quad containingSsdq = scrollContainer.ScreenSpaceDrawQuad; - - // Using DelayedLoadWrappers would only allow us to load content when on screen, but we want to preload while off-screen - // to provide a better user experience. - - // This is tracking time that this drawable is updating since the last pool. - // This is intended to provide a debounce so very fast scrolls (from one end to the other of the carousel) - // don't cause huge overheads. - // - // We increase the delay based on distance from centre, so the beatmaps the user is currently looking at load first. - float timeUpdatingBeforeLoad = 50 + Math.Abs(containingSsdq.Centre.Y - ScreenSpaceDrawQuad.Centre.Y) / containingSsdq.Height * 100; - - Debug.Assert(Item != null); - - // A load is already in progress if the cancellation token is non-null. - if (loadCancellation != null) - return; - - timeSinceUnpool += Time.Elapsed; - - // We only trigger a load after this set has been in an updating state for a set amount of time. - if (timeSinceUnpool <= timeUpdatingBeforeLoad) - return; - - loadCancellation = new CancellationTokenSource(); - - LoadComponentsAsync(new CompositeDrawable[] - { - // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). - new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) - { - RelativeSizeAxes = Axes.Both, - }, - new SetPanelContent((CarouselBeatmapSet)Item) - { - Depth = float.MinValue, - RelativeSizeAxes = Axes.Both, - } - }, drawables => - { - Header.AddRange(drawables); - drawables.ForEach(d => d.FadeInFromZero(150)); - }, loadCancellation.Token); - } - - private void updateBeatmapYPositions() - { - if (beatmapContainer == null) - return; - - if (beatmapsLoadTask == null || !beatmapsLoadTask.IsCompleted) - return; - - float yPos = DrawableCarouselBeatmap.CAROUSEL_BEATMAP_SPACING; - - bool isSelected = Item?.State.Value == CarouselItemState.Selected; - - foreach (var panel in beatmapContainer) - { - Debug.Assert(panel.Item != null); - - if (isSelected) - { - panel.MoveToY(yPos, 800, Easing.OutQuint); - yPos += panel.Item.TotalHeight; - } - else - panel.MoveToY(0, 800, Easing.OutQuint); - } - } - - public MenuItem[] ContextMenuItems - { - get - { - Debug.Assert(beatmapSet != null); - - List items = new List(); - - if (Item?.State.Value == CarouselItemState.NotSelected) - items.Add(new OsuMenuItem("Expand", MenuItemType.Highlighted, () => Item.State.Value = CarouselItemState.Selected)); - - if (mainMenuItems != null) - items.AddRange(mainMenuItems); - - if (beatmapSet.OnlineID > 0 && viewDetails != null) - items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineID))); - - var collectionItems = realm.Realm.All() - .OrderBy(c => c.Name) - .AsEnumerable() - .Select(createCollectionMenuItem) - .ToList(); - - if (manageCollectionsDialog != null) - collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); - - items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); - - if (beatmapSet.Beatmaps.Any(b => b.Hidden)) - items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); - - if (beatmapSet.GetOnlineURL(api, ruleset.Value) is string url) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard(url))); - - if (dialogOverlay != null) - items.Add(new OsuMenuItem("Delete...", MenuItemType.Destructive, () => dialogOverlay.Push(new BeatmapDeleteDialog(beatmapSet)))); - return items.ToArray(); - } - } - - private MenuItem createCollectionMenuItem(BeatmapCollection collection) - { - Debug.Assert(beatmapSet != null); - - TernaryState state; - - int countExisting = beatmapSet.Beatmaps.Count(b => collection.BeatmapMD5Hashes.Contains(b.MD5Hash)); - - if (countExisting == beatmapSet.Beatmaps.Count) - state = TernaryState.True; - else if (countExisting > 0) - state = TernaryState.Indeterminate; - else - state = TernaryState.False; - - var liveCollection = collection.ToLive(realm); - - return new TernaryStateToggleMenuItem(collection.Name, MenuItemType.Standard, s => - { - liveCollection.PerformWrite(c => - { - foreach (var b in beatmapSet.Beatmaps) - { - switch (s) - { - case TernaryState.True: - if (c.BeatmapMD5Hashes.Contains(b.MD5Hash)) - continue; - - c.BeatmapMD5Hashes.Add(b.MD5Hash); - break; - - case TernaryState.False: - c.BeatmapMD5Hashes.Remove(b.MD5Hash); - break; - } - } - }); - }) - { - State = { Value = state } - }; - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs deleted file mode 100644 index 10921c331eff..000000000000 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs +++ /dev/null @@ -1,177 +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.Diagnostics; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Pooling; -using osu.Framework.Input.Events; -using osuTK; - -namespace osu.Game.Screens.Select.Carousel -{ - public abstract partial class DrawableCarouselItem : PoolableDrawable - { - public const float MAX_HEIGHT = 80; - - public override bool IsPresent => base.IsPresent || Item?.Visible == true; - - public override bool HandlePositionalInput => Item?.Visible == true; - public override bool PropagatePositionalInputSubTree => Item?.Visible == true; - - public readonly CarouselHeader Header; - - /// - /// Optional content which sits below the header. - /// - protected readonly Container Content; - - protected readonly Container MovementContainer; - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => - Header.ReceivePositionalInputAt(screenSpacePos); - - private CarouselItem? item; - - public CarouselItem? Item - { - get => item; - set - { - if (item == value) - return; - - if (item != null) - { - item.Filtered.ValueChanged -= onStateChange; - item.State.ValueChanged -= onStateChange; - - Header.State.UnbindFrom(item.State); - - if (item is CarouselGroup group) - { - foreach (var c in group.Items) - c.Filtered.ValueChanged -= onStateChange; - } - } - - item = value; - - if (IsLoaded && !IsDisposed) - UpdateItem(); - } - } - - protected DrawableCarouselItem() - { - RelativeSizeAxes = Axes.X; - - Alpha = 0; - - InternalChildren = new Drawable[] - { - MovementContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - Header = new CarouselHeader(), - Content = new Container - { - RelativeSizeAxes = Axes.Both, - } - } - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - UpdateItem(); - } - - protected override void Update() - { - base.Update(); - Content.Y = Header.Height; - } - - protected virtual void UpdateItem() - { - if (Item == null) - return; - - Scheduler.AddOnce(ApplyState); - - Item.Filtered.ValueChanged += onStateChange; - Item.State.ValueChanged += onStateChange; - - Header.State.BindTo(Item.State); - - if (Item is CarouselGroup group) - { - foreach (var c in group.Items) - c.Filtered.ValueChanged += onStateChange; - } - } - - private void onStateChange(ValueChangedEvent obj) => Scheduler.AddOnce(ApplyState); - - private void onStateChange(ValueChangedEvent _) => Scheduler.AddOnce(ApplyState); - - protected virtual void ApplyState() - { - Debug.Assert(Item != null); - - // Use the fact that we know the precise height of the item from the model to avoid the need for AutoSize overhead. - // Additionally, AutoSize doesn't work well due to content starting off-screen and being masked away. - Height = Item.TotalHeight; - - switch (Item.State.Value) - { - case CarouselItemState.NotSelected: - Deselected(); - break; - - case CarouselItemState.Selected: - Selected(); - break; - } - - if (!Item.Visible) - this.FadeOut(100, Easing.OutQuint); - else - this.FadeIn(400, Easing.OutQuint); - } - - protected virtual void Selected() - { - Debug.Assert(Item != null); - } - - protected virtual void Deselected() - { - } - - protected override bool OnClick(ClickEvent e) - { - Debug.Assert(Item != null); - - Item.State.Value = CarouselItemState.Selected; - return true; - } - - protected override bool OnHover(HoverEvent e) => true; - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - // This is important to clean up event subscriptions. - Item = null; - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs b/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs deleted file mode 100644 index cd8e20ad392e..000000000000 --- a/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Input.Events; -using osu.Game.Beatmaps.Drawables; - -namespace osu.Game.Screens.Select.Carousel -{ - public partial class FilterableDifficultyIcon : DifficultyIcon - { - private readonly BindableBool filtered = new BindableBool(); - - public bool IsFiltered => filtered.Value; - - public readonly CarouselBeatmap Item; - - public FilterableDifficultyIcon(CarouselBeatmap item) - : base(item.BeatmapInfo) - { - filtered.BindTo(item.Filtered); - filtered.ValueChanged += isFiltered => Schedule(() => this.FadeTo(isFiltered.NewValue ? 0.1f : 1, 100)); - filtered.TriggerChange(); - - Item = item; - } - - protected override bool OnClick(ClickEvent e) - { - Item.State.Value = CarouselItemState.Selected; - return true; - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/GroupedDifficultyIcon.cs b/osu.Game/Screens/Select/Carousel/GroupedDifficultyIcon.cs deleted file mode 100644 index 3de44fa0326a..000000000000 --- a/osu.Game/Screens/Select/Carousel/GroupedDifficultyIcon.cs +++ /dev/null @@ -1,60 +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.Framework.Input.Events; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets; -using osuTK.Graphics; - -namespace osu.Game.Screens.Select.Carousel -{ - /// - /// A difficulty icon that contains a counter on the right-side of it. - /// - /// - /// Used in cases when there are too many difficulty icons to show. - /// - public partial class GroupedDifficultyIcon : DifficultyIcon - { - public readonly List Items; - - public GroupedDifficultyIcon(List items, RulesetInfo ruleset) - : base(items.OrderBy(b => b.BeatmapInfo.StarRating).Last().BeatmapInfo, ruleset) - { - Items = items; - - foreach (var item in items) - item.Filtered.BindValueChanged(_ => Scheduler.AddOnce(updateFilteredDisplay)); - - AddInternal(new OsuSpriteText - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Padding = new MarginPadding { Left = Size.X }, - Margin = new MarginPadding { Left = 2, Right = 5 }, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), - Text = items.Count.ToString(), - Colour = Color4.White, - }); - - updateFilteredDisplay(); - } - - protected override bool OnClick(ClickEvent e) - { - Items.First().State.Value = CarouselItemState.Selected; - return true; - } - - private void updateFilteredDisplay() - { - // for now, fade the whole group based on the ratio of hidden items. - this.FadeTo(1 - 0.9f * ((float)Items.Count(i => i.Filtered.Value) / Items.Count), 100); - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs b/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs deleted file mode 100644 index b8729b7174d2..000000000000 --- a/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Select.Carousel -{ - public partial class SetPanelBackground : BufferedContainer - { - public SetPanelBackground(IWorkingBeatmap working) - : base(cachedFrameBuffer: true) - { - RedrawOnScale = false; - - Children = new Drawable[] - { - new PanelBeatmapBackground(working) - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fill, - }, - new FillFlowContainer - { - Depth = -1, - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle - Shear = new Vector2(0.8f, 0), - Alpha = 0.5f, - Children = new[] - { - // The left half with no gradient applied - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Width = 0.4f, - }, - // Piecewise-linear gradient with 3 segments to make it appear smoother - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)), - Width = 0.05f, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)), - Width = 0.2f, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)), - Width = 0.05f, - }, - } - }, - }; - } - - public partial class PanelBeatmapBackground : Sprite - { - private readonly IWorkingBeatmap working; - - public PanelBeatmapBackground(IWorkingBeatmap working) - { - ArgumentNullException.ThrowIfNull(working); - - this.working = working; - } - - [BackgroundDependencyLoader] - private void load() - { - Texture = working.GetPanelBackground(); - } - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs deleted file mode 100644 index c3ded16bd244..000000000000 --- a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs +++ /dev/null @@ -1,117 +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.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Localisation; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK; - -namespace osu.Game.Screens.Select.Carousel -{ - public partial class SetPanelContent : CompositeDrawable - { - // Disallow interacting with difficulty icons on a panel until the panel has been selected. - public override bool PropagatePositionalInputSubTree => carouselSet.State.Value == CarouselItemState.Selected; - - private readonly CarouselBeatmapSet carouselSet; - - private FillFlowContainer iconFlow = null!; - - public SetPanelContent(CarouselBeatmapSet carouselSet) - { - this.carouselSet = carouselSet; - - // required to ensure we load as soon as any part of the panel comes on screen - RelativeSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - var beatmapSet = carouselSet.BeatmapSet; - - InternalChild = new FillFlowContainer - { - // required to ensure we load as soon as any part of the panel comes on screen - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Top = 5, Left = 18, Right = 10, Bottom = 10 }, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = new RomanisableString(beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title), - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 22, italics: true), - Shadow = true, - }, - new OsuSpriteText - { - Text = new RomanisableString(beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist), - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true), - Shadow = true, - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 5 }, - Spacing = new Vector2(5), - Children = new[] - { - beatmapSet.AllBeatmapsUpToDate - ? Empty() - : new Container - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Children = new Drawable[] - { - new UpdateBeatmapSetButton(beatmapSet), - } - }, - new BeatmapSetOnlineStatusPill - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - TextSize = 11, - TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, - Status = beatmapSet.Status - }, - iconFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Spacing = new Vector2(3), - }, - } - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - iconFlow.ChildrenEnumerable = getDifficultyIcons(); - } - - private const int maximum_difficulty_icons = 18; - - private IEnumerable getDifficultyIcons() - { - var beatmaps = carouselSet.Beatmaps.ToList(); - - return beatmaps.Count > maximum_difficulty_icons - ? beatmaps.GroupBy(b => b.BeatmapInfo.Ruleset) - .Select(group => new GroupedDifficultyIcon(group.ToList(), group.Last().BeatmapInfo.Ruleset)) - : beatmaps.Select(b => new FilterableDifficultyIcon(b)); - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs deleted file mode 100644 index 6f1f2e83704d..000000000000 --- a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Online.API; -using osu.Game.Online.Leaderboards; -using osu.Game.Rulesets; -using osu.Game.Scoring; -using osuTK; -using Realms; - -namespace osu.Game.Screens.Select.Carousel -{ - public partial class TopLocalRank : CompositeDrawable - { - private readonly BeatmapInfo beatmapInfo; - - [Resolved] - private IBindable ruleset { get; set; } = null!; - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - private IDisposable? scoreSubscription; - - private readonly UpdateableRank updateable; - - public ScoreRank? DisplayedRank => updateable.Rank; - - public TopLocalRank(BeatmapInfo beatmapInfo) - { - this.beatmapInfo = beatmapInfo; - - AutoSizeAxes = Axes.Both; - - InternalChild = updateable = new UpdateableRank - { - Size = new Vector2(40, 20), - Alpha = 0, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - ruleset.BindValueChanged(_ => - { - scoreSubscription?.Dispose(); - scoreSubscription = realm.RegisterForNotifications(r => - r.GetAllLocalScoresForUser(api.LocalUser.Value.Id) - .Filter($@"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" - + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1", beatmapInfo.ID, ruleset.Value.ShortName), - localScoresChanged); - }, true); - - void localScoresChanged(IRealmCollection sender, ChangeSet? changes) - { - // This subscription may fire from changes to linked beatmaps, which we don't care about. - // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. - if (changes?.HasCollectionChanges() == false) - return; - - ScoreInfo? topScore = sender.MaxBy(info => (info.TotalScore, -info.Date.UtcDateTime.Ticks)); - updateable.Rank = topScore?.Rank; - updateable.Alpha = topScore != null ? 1 : 0; - } - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - scoreSubscription?.Dispose(); - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs b/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs deleted file mode 100644 index d41870f1d2ef..000000000000 --- a/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; -using osu.Game.Overlays; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Select.Carousel -{ - public partial class UpdateBeatmapSetButton : OsuAnimatedButton - { - private readonly BeatmapSetInfo beatmapSetInfo; - private SpriteIcon icon = null!; - private Box progressFill = null!; - - [Resolved] - private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - [Resolved] - private LoginOverlay? loginOverlay { get; set; } - - [Resolved] - private IDialogOverlay? dialogOverlay { get; set; } - - public UpdateBeatmapSetButton(BeatmapSetInfo beatmapSetInfo) - { - this.beatmapSetInfo = beatmapSetInfo; - - AutoSizeAxes = Axes.Both; - - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - } - - private Bindable preferNoVideo = null!; - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - const float icon_size = 14; - - preferNoVideo = config.GetBindable(OsuSetting.PreferNoVideo); - - Content.Anchor = Anchor.CentreLeft; - Content.Origin = Anchor.CentreLeft; - - Content.AddRange(new Drawable[] - { - progressFill = new Box - { - Colour = Color4.White, - Alpha = 0.2f, - Blending = BlendingParameters.Additive, - RelativeSizeAxes = Axes.Both, - Width = 0, - }, - new FillFlowContainer - { - Padding = new MarginPadding { Horizontal = 5, Vertical = 3 }, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(4), - Children = new Drawable[] - { - new Container - { - Size = new Vector2(icon_size), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Children = new Drawable[] - { - icon = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = FontAwesome.Solid.SyncAlt, - Size = new Vector2(icon_size), - }, - } - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.Default.With(weight: FontWeight.Bold), - Text = "Update", - } - } - }, - }); - - Action = updateBeatmap; - } - - private bool updateConfirmed; - - private void updateBeatmap() - { - if (!api.IsLoggedIn) - { - loginOverlay?.Show(); - return; - } - - if (dialogOverlay != null && beatmapSetInfo.Status == BeatmapOnlineStatus.LocallyModified && !updateConfirmed) - { - dialogOverlay.Push(new UpdateLocalConfirmationDialog(() => - { - updateConfirmed = true; - updateBeatmap(); - })); - - return; - } - - updateConfirmed = false; - - beatmapDownloader.DownloadAsUpdate(beatmapSetInfo, preferNoVideo.Value); - attachExistingDownload(); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - icon.Spin(4000, RotationDirection.Clockwise); - } - - private void attachExistingDownload() - { - var download = beatmapDownloader.GetExistingDownload(beatmapSetInfo); - - if (download != null) - { - Enabled.Value = false; - TooltipText = string.Empty; - - download.DownloadProgressed += progress => progressFill.ResizeWidthTo(progress, 100, Easing.OutQuint); - download.Failure += _ => attachExistingDownload(); - } - else - { - Enabled.Value = true; - TooltipText = "Update beatmap with online changes"; - - progressFill.ResizeWidthTo(0, 100, Easing.OutQuint); - } - } - - protected override bool OnHover(HoverEvent e) - { - icon.Spin(400, RotationDirection.Clockwise, icon.Rotation); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - icon.Spin(4000, RotationDirection.Clockwise, icon.Rotation); - base.OnHoverLost(e); - } - } -} diff --git a/osu.Game/Screens/SelectV2/CollectionDropdown.cs b/osu.Game/Screens/Select/CollectionDropdown.cs similarity index 97% rename from osu.Game/Screens/SelectV2/CollectionDropdown.cs rename to osu.Game/Screens/Select/CollectionDropdown.cs index a333be57761c..ee2263754104 100644 --- a/osu.Game/Screens/SelectV2/CollectionDropdown.cs +++ b/osu.Game/Screens/Select/CollectionDropdown.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -22,10 +23,11 @@ using osuTK; using Realms; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { /// /// A dropdown to select the collection to be used to filter results. + /// WARNING: TODO: we have TWO `CollectionDropdowns` with diverging functionality. This is not good. /// public partial class CollectionDropdown : ShearedDropdown // TODO: partial class under FilterControl? { @@ -237,11 +239,11 @@ private void addOrRemove() { Debug.Assert(collection != null); - collection.PerformWrite(c => + Task.Run(() => collection.PerformWrite(c => { if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash); - }); + })); } protected override Drawable CreateContent() => (Content)base.CreateContent(); diff --git a/osu.Game/Screens/SelectV2/FilterControl.DifficultyRangeSlider.cs b/osu.Game/Screens/Select/FilterControl.DifficultyRangeSlider.cs similarity index 92% rename from osu.Game/Screens/SelectV2/FilterControl.DifficultyRangeSlider.cs rename to osu.Game/Screens/Select/FilterControl.DifficultyRangeSlider.cs index f65c17bddf01..902b335c621c 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.DifficultyRangeSlider.cs +++ b/osu.Game/Screens/Select/FilterControl.DifficultyRangeSlider.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Layout; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; @@ -20,7 +21,7 @@ using osu.Game.Utils; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class FilterControl { @@ -164,15 +165,20 @@ protected override void LoadComplete() protected override void UpdateDisplay(double value) { Colour4 nubColour = ColourUtils.SampleFromLinearGradient(spectrum, (float)Math.Round(value, 2, MidpointRounding.AwayFromZero)); - nubColour = nubColour.Lighten(0.4f); - if (value >= 8.0) + // Handle edge case colors for color harmony + if (value >= 7.5 && value < 8.0) + nubColour = Interpolation.ValueAt(value, nubColour, colours.Gray4, 7.5, 8.0); + else if (value >= 8.0) nubColour = colours.Gray4; Nub.AccentColour = nubColour; - Nub.GlowingAccentColour = nubColour.Lighten(0.2f); + Nub.GlowingAccentColour = nubColour.Lighten(0.1f); Nub.ShadowColour = Color4.Black.Opacity(0.2f); - NubText.Colour = OsuColour.ForegroundTextColourFor(nubColour); + NubText.Colour = colours.ForStarDifficultyText(value); + // Except for infinity, which should be white + if (Current.IsDefault && isUpper) + NubText.Colour = OsuColour.ForegroundTextColourFor(nubColour); base.UpdateDisplay(value); } diff --git a/osu.Game/Screens/Select/FilterControl.ScopedBeatmapSetDisplay.cs b/osu.Game/Screens/Select/FilterControl.ScopedBeatmapSetDisplay.cs new file mode 100644 index 000000000000..040081605f89 --- /dev/null +++ b/osu.Game/Screens/Select/FilterControl.ScopedBeatmapSetDisplay.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Select +{ + public partial class FilterControl + { + public partial class ScopedBeatmapSetDisplay : OsuClickableContainer, IKeyBindingHandler + { + public IBindable ScopedBeatmapSet { get; } = new Bindable(); + + private Box flashLayer = null!; + private Container content = null!; + private OsuTextFlowContainer text = null!; + + private const float transition_duration = 300; + + public ScopedBeatmapSetDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + CornerRadius = 8f; + Masking = true; + } + + [BackgroundDependencyLoader] + private void load(ISongSelect? songSelect, OverlayColourProvider colourProvider) + { + Content.AutoSizeEasing = Easing.OutQuint; + Content.AutoSizeDuration = transition_duration; + + AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Highlight1, + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + BypassAutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding + { + Horizontal = 6, + Vertical = 2, + }, + Children = new Drawable[] + { + text = new OsuTextFlowContainer(t => t.Font = OsuFont.Style.Body) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Colour = colourProvider.Background6, + Padding = new MarginPadding { Right = 80, Vertical = 5 } + }, + new ShearedButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Width = 80, + Text = CommonStrings.Back, + RelativeSizeAxes = Axes.Y, + Height = 1, + Action = () => Action?.Invoke(), + } + } + }, + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White, + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + }); + Action = () => songSelect?.UnscopeBeatmapSet(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScopedBeatmapSet.BindValueChanged(_ => updateState(), true); + } + + private void updateState() + { + if (ScopedBeatmapSet.Value != null) + { + content.BypassAutoSizeAxes = Axes.None; + text.Clear(); + text.AddText(SongSelectStrings.TemporarilyShowingAllBeatmapsIn); + text.AddText(@" "); + text.AddText(ScopedBeatmapSet.Value.Metadata.GetDisplayTitleRomanisable(), t => t.Font = OsuFont.Style.Body.With(weight: FontWeight.Bold)); + } + else + { + flashLayer.FadeOutFromOne(transition_duration, Easing.OutQuint); + content.BypassAutoSizeAxes = Axes.Y; + } + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (ScopedBeatmapSet.Value != null && e.Action == GlobalAction.Back && !e.Repeat) + { + TriggerClick(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index 4781a3dee71d..4867e7de8c62 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -1,29 +1,28 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Configuration; -using osu.Game.Graphics; +using osu.Game.Database; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; using osu.Game.Localisation; -using osu.Game.Resources.Localisation.Web; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select.Filter; @@ -32,277 +31,350 @@ namespace osu.Game.Screens.Select { - public partial class FilterControl : Container + public sealed partial class FilterControl : OverlayContainer { - public const float HEIGHT = 2 * side_margin + 120; - - private const float side_margin = 10; + // taken from draw visualiser. used for carousel alignment purposes. + public const float HEIGHT_FROM_SCREEN_TOP = 141 - corner_radius; - public Action FilterChanged; + private const float corner_radius = 10; - public Bindable CurrentTextSearch => searchTextBox.Current; + public IBindable ScopedBeatmapSet { get; } = new Bindable(); - public LocalisableString InformationalText - { - get => searchTextBox.FilterText.Text; - set => searchTextBox.FilterText.Text = value; - } + private SongSelectSearchTextBox searchTextBox = null!; + private ShearedToggleButton showConvertedBeatmapsButton = null!; + private DifficultyRangeSlider difficultyRangeSlider = null!; + private ShearedDropdown sortDropdown = null!; + private ShearedDropdown groupDropdown = null!; + private CollectionDropdown collectionDropdown = null!; - private OsuTabControl sortTabs; - private Bindable sortMode; - private Bindable groupMode; - private FilterControlTextBox searchTextBox; - private CollectionDropdown collectionDropdown; + /// + /// An optional method which can force certain criteria adjustments. + /// + public Action? ApplyRequiredCriteria { get; set; } - [CanBeNull] - private FilterCriteria currentCriteria; + [Resolved] + private ISongSelect? songSelect { get; set; } - public virtual FilterCriteria CreateCriteria() - { - string query = searchTextBox.Text; + [Resolved] + private IBindable ruleset { get; set; } = null!; - var criteria = new FilterCriteria - { - Group = groupMode.Value, - Sort = sortMode.Value, - AllowConvertedBeatmaps = showConverted.Value, - Ruleset = ruleset.Value, - Mods = mods.Value, - CollectionBeatmapMD5Hashes = collectionDropdown.Current.Value?.Collection?.PerformRead(c => c.BeatmapMD5Hashes).ToImmutableHashSet() - }; + [Resolved] + private IBindable> mods { get; set; } = null!; - if (!minimumStars.IsDefault) - criteria.UserStarDifficulty.Min = minimumStars.Value; + [Resolved] + private OsuConfigManager config { get; set; } = null!; - if (!maximumStars.IsDefault) - criteria.UserStarDifficulty.Max = maximumStars.Value; + [Resolved] + private RealmAccess realm { get; set; } = null!; - criteria.RulesetCriteria = ruleset.Value.CreateInstance().CreateRulesetFilterCriteria(); + private IBindable localUser = null!; + private readonly IBindableList localUserFavouriteBeatmapSets = new BindableList(); - FilterQueryParser.ApplyQueries(criteria, query); - return criteria; + public LocalisableString StatusText + { + get => searchTextBox.StatusText; + set => searchTextBox.StatusText = value; } - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => - base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); + public event Action? CriteriaChanged; + + private FilterCriteria currentCriteria = null!; - [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuColour colours, OsuConfigManager config) + private IDisposable? collectionsSubscription; + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) { - sortMode = config.GetBindable(OsuSetting.SongSelectSortingMode); - groupMode = config.GetBindable(OsuSetting.SongSelectGroupMode); + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Shear = OsuGame.SHEAR; + Margin = new MarginPadding { Top = -corner_radius, Right = -40 }; - Children = new Drawable[] + InternalChildren = new Drawable[] { - new Box + new Container { - Colour = OsuColour.Gray(0.05f), - Alpha = 0.96f, - Width = 2, RelativeSizeAxes = Axes.Both, + CornerRadius = corner_radius, + Masking = true, + Child = new WedgeBackground + { + Anchor = Anchor.TopRight, + Scale = new Vector2(-1, 1), + } }, - new Container + new ReverseChildIDFillFlowContainer { - Padding = new MarginPadding(side_margin), - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - // Reverse ChildID so that dropdowns in the top section appear on top of the bottom section. - Child = new ReverseChildIDFillFlowContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 5f), + Padding = new MarginPadding { Top = corner_radius + 5, Bottom = 2, Right = 40f, Left = 2f }, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Spacing = new Vector2(0, 5), - Children = new Drawable[] + new Container { - searchTextBox = new FilterControlTextBox + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Child = searchTextBox = new SongSelectSearchTextBox { RelativeSizeAxes = Axes.X, + HoldFocus = true, + ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet }, }, - new Box + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] { - RelativeSizeAxes = Axes.X, - Height = 1, - Colour = OsuColour.Gray(80), + new Dimension(), + new Dimension(GridSizeMode.Absolute), // can probably be removed? + new Dimension(GridSizeMode.AutoSize), }, - new GridContainer + Content = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] + new[] { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), - new Dimension(), - new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), - new Dimension(GridSizeMode.AutoSize), - }, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - Content = new[] - { - new[] + difficultyRangeSlider = new DifficultyRangeSlider { - new OsuSpriteText - { - Text = SortStrings.Default, - Font = OsuFont.GetFont(size: 14), - Margin = new MarginPadding(5), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - Empty(), - sortTabs = new OsuTabControl - { - RelativeSizeAxes = Axes.X, - Height = 24, - AutoSort = true, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AccentColour = colours.GreenLight, - Current = { BindTarget = sortMode } - }, - Empty(), - new OsuTabControlCheckbox - { - Text = "Show converted", - Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - } - } + RelativeSizeAxes = Axes.X, + MinRange = 0.1f, + }, + Empty(), + showConvertedBeatmapsButton = new ShearedToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.X, + Text = UserInterfaceStrings.ShowConverts, + Height = 30f, + }, + }, + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + Height = 30, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(maxSize: 180), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(maxSize: 180), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), }, - new Container + Content = new[] { - RelativeSizeAxes = Axes.X, - Height = 40, - Children = new Drawable[] + new[] { - new RangeSlider + sortDropdown = new ShearedDropdown(SongSelectStrings.Sort) + { + RelativeSizeAxes = Axes.X, + Items = Enum.GetValues(), + }, + Empty(), + groupDropdown = new ShearedDropdown(SongSelectStrings.Group) { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Label = "Difficulty range", - LowerBound = config.GetBindable(OsuSetting.DisplayStarsMinimum), - UpperBound = config.GetBindable(OsuSetting.DisplayStarsMaximum), - RelativeSizeAxes = Axes.Both, - Width = 0.48f, - DefaultStringLowerBound = "0", - DefaultStringUpperBound = "∞", - DefaultTooltipUpperBound = UserInterfaceStrings.NoLimit, - TooltipSuffix = "stars" + RelativeSizeAxes = Axes.X, + Items = Enum.GetValues(), }, + Empty(), collectionDropdown = new CollectionDropdown { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RequestFilter = updateCriteria, RelativeSizeAxes = Axes.X, - Y = 4, - Width = 0.5f, - } + }, } - }, + } + }, + new ScopedBeatmapSetDisplay + { + ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet }, } - } + }, } }; - config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConverted); - showConverted.ValueChanged += _ => updateCriteria(); + localUser = api.LocalUser.GetBoundCopy(); + localUserFavouriteBeatmapSets.BindTo(api.LocalUserState.FavouriteBeatmapSets); + } - config.BindWith(OsuSetting.DisplayStarsMinimum, minimumStars); - minimumStars.ValueChanged += _ => updateCriteria(); + protected override void LoadComplete() + { + base.LoadComplete(); - config.BindWith(OsuSetting.DisplayStarsMaximum, maximumStars); - maximumStars.ValueChanged += _ => updateCriteria(); + difficultyRangeSlider.LowerBound = config.GetBindable(OsuSetting.DisplayStarsMinimum); + difficultyRangeSlider.UpperBound = config.GetBindable(OsuSetting.DisplayStarsMaximum); + config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConvertedBeatmapsButton.Active); + config.BindWith(OsuSetting.SongSelectSortingMode, sortDropdown.Current); + config.BindWith(OsuSetting.SongSelectGroupMode, groupDropdown.Current); ruleset.BindValueChanged(_ => updateCriteria()); mods.BindValueChanged(m => { - // Mods are updated once by the mod select overlay when song select is entered, - // regardless of if there are any mods or any changes have taken place. - // Updating the criteria here so early triggers a re-ordering of panels on song select, via... some mechanism. - // Todo: Investigate/fix and potentially remove this. + // The following is a note carried from old song select and may not be a valid reason anymore: + // // Mods are updated once by the mod select overlay when song select is entered, + // // regardless of if there are any mods or any changes have taken place. + // // Updating the criteria here so early triggers a re-ordering of panels on song select, via... some mechanism. + // // Todo: Investigate/fix and potentially remove this. + // TODO: this might be simply removable with the new song select & carousel code. if (m.NewValue.SequenceEqual(m.OldValue)) return; - if (currentCriteria?.RulesetCriteria?.FilterMayChangeFromMods(m) == true) + var rulesetCriteria = currentCriteria.RulesetCriteria; + if (rulesetCriteria?.FilterMayChangeFromMods(m) == true) updateCriteria(); }); - groupMode.BindValueChanged(_ => updateCriteria()); - sortMode.BindValueChanged(_ => updateCriteria()); + searchTextBox.Current.BindValueChanged(_ => updateCriteria()); + difficultyRangeSlider.LowerBound.BindValueChanged(_ => updateCriteria()); + difficultyRangeSlider.UpperBound.BindValueChanged(_ => updateCriteria()); + showConvertedBeatmapsButton.Active.BindValueChanged(_ => updateCriteria()); + sortDropdown.Current.BindValueChanged(_ => updateCriteria()); + groupDropdown.Current.BindValueChanged(_ => updateCriteria()); + collectionDropdown.Current.BindValueChanged(v => + { + // The hope would be that this never arrives here, but due to bindings receiving changes before + // local ValueChanged events, that's not the case (see https://github.com/ppy/osu-framework/pull/1545). + if (v.NewValue is ManageCollectionsFilterMenuItem || v.OldValue is ManageCollectionsFilterMenuItem) + return; - searchTextBox.Current.ValueChanged += _ => updateCriteria(); + updateCriteria(); + }); + collectionsSubscription = realm.RegisterForNotifications(r => r.All(), (collections, changeSet) => + { + if (changeSet != null && groupDropdown.Current.Value == GroupMode.Collections) + updateCriteria(); + }); + + localUser.BindValueChanged(_ => updateCriteria()); + localUserFavouriteBeatmapSets.BindCollectionChanged((_, _) => updateCriteria()); + ScopedBeatmapSet.BindValueChanged(_ => updateCriteria(clearScopedSet: false)); updateCriteria(); } - public void Deactivate() + protected override void Dispose(bool isDisposing) { - searchTextBox.ReadOnly = true; - searchTextBox.HoldFocus = false; - if (searchTextBox.HasFocus) - GetContainingFocusManager()!.ChangeFocus(searchTextBox); + base.Dispose(isDisposing); + collectionsSubscription?.Dispose(); } - public void Activate() + /// + /// Creates a based on the current state of the controls. + /// + public FilterCriteria CreateCriteria() { - searchTextBox.ReadOnly = false; - searchTextBox.HoldFocus = true; - } + string query = searchTextBox.Current.Value; + bool isValidUser = localUser.Value.Id > 1; - [Resolved] - private IBindable ruleset { get; set; } = null!; + var criteria = new FilterCriteria + { + SelectedBeatmapSet = ScopedBeatmapSet.Value, + Sort = sortDropdown.Current.Value, + Group = groupDropdown.Current.Value, + AllowConvertedBeatmaps = showConvertedBeatmapsButton.Active.Value, + Ruleset = ruleset.Value, + Mods = mods.Value, + CollectionBeatmapMD5Hashes = collectionDropdown.Current.Value?.Collection?.PerformRead(c => c.BeatmapMD5Hashes).ToImmutableHashSet(), + LocalUserId = isValidUser ? localUser.Value.Id : null, + LocalUserUsername = isValidUser ? localUser.Value.Username : null, + }; - [Resolved] - private IBindable> mods { get; set; } = null!; + if (!difficultyRangeSlider.LowerBound.IsDefault) + criteria.UserStarDifficulty.Min = difficultyRangeSlider.LowerBound.Value; + + if (!difficultyRangeSlider.UpperBound.IsDefault) + criteria.UserStarDifficulty.Max = difficultyRangeSlider.UpperBound.Value; + + criteria.RulesetCriteria = ruleset.Value.CreateInstance().CreateRulesetFilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); - private readonly Bindable showConverted = new Bindable(); - private readonly Bindable minimumStars = new BindableDouble(); - private readonly Bindable maximumStars = new BindableDouble(); + ApplyRequiredCriteria?.Invoke(criteria); - private void updateCriteria() => FilterChanged?.Invoke(currentCriteria = CreateCriteria()); + return criteria; + } - protected override bool OnClick(ClickEvent e) => true; + private void updateCriteria(bool clearScopedSet = true) + { + if (clearScopedSet && ScopedBeatmapSet.Value != null) + { + songSelect?.UnscopeBeatmapSet(); + // because `ScopedBeatmapSet` has a value change callback bound to it that calls `updateCriteria()` again, + // we can just do nothing other than clear it to avoid extra work and duplicated `CriteriaChanged` invocations + return; + } - protected override bool OnHover(HoverEvent e) => true; + currentCriteria = CreateCriteria(); + CriteriaChanged?.Invoke(currentCriteria); + } - internal partial class FilterControlTextBox : SeekLimitedSearchTextBox + /// + /// Set the query to the search text box. + /// + /// The string to search. + public void Search(string query) { - private const float filter_text_size = 12; + searchTextBox.Current.Value = query; + } - public OsuSpriteText FilterText { get; private set; } + protected override void PopIn() + { + this.MoveToX(0, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeIn(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void PopOut() + { + this.MoveToX(150, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); + } - public FilterControlTextBox() + internal partial class SongSelectSearchTextBox : ShearedFilterTextBox + { + public IBindable ScopedBeatmapSet { get; } = new Bindable(); + + protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox { - Height += filter_text_size; - TextContainer.Height *= (Height - filter_text_size) / Height; - TextContainer.Margin = new MarginPadding { Bottom = filter_text_size }; - } + ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet }, + }; - [BackgroundDependencyLoader] - private void load(OsuColour colours) + private partial class InnerTextBox : InnerFilterTextBox { - TextContainer.Add(FilterText = new OsuSpriteText + public IBindable ScopedBeatmapSet { get; } = new Bindable(); + + public override bool HandleLeftRightArrows => false; + + public override bool OnPressed(KeyBindingPressEvent e) { - Anchor = Anchor.BottomLeft, - Origin = Anchor.TopLeft, - Depth = float.MinValue, - Font = OsuFont.Default.With(size: filter_text_size, weight: FontWeight.SemiBold), - Margin = new MarginPadding { Top = 2, Left = 2 }, - Colour = colours.Yellow - }); - } + if (e.Action == GlobalAction.Back && ScopedBeatmapSet.Value != null) + return false; - public override bool OnPressed(KeyBindingPressEvent e) - { - // the "cut" platform key binding (shift-delete) conflicts with the beatmap deletion action. - if (e.Action == PlatformAction.Cut && e.ShiftPressed && e.CurrentState.Keyboard.Keys.IsPressed(Key.Delete)) - return false; + return base.OnPressed(e); + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + // Conflicts with default group navigation keys (shift-left shift-right). + if (e.Action == PlatformAction.SelectBackwardChar || e.Action == PlatformAction.SelectForwardChar) + return false; + + // the "cut" platform key binding (shift-delete) conflicts with the beatmap deletion action. + if (e.Action == PlatformAction.Cut && e.ShiftPressed && e.CurrentState.Keyboard.Keys.IsPressed(Key.Delete)) + return false; - return base.OnPressed(e); + return base.OnPressed(e); + } } } } diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 8cf3bda1c55e..0adcf5d45421 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -68,6 +68,7 @@ private static bool tryParseKeywordCriteria(FilterCriteria criteria, string key, case "ranked": return tryUpdateRankedDateRange(ref criteria.DateRanked, op, value); + case "created": case "submitted": return tryUpdateRankedDateRange(ref criteria.DateSubmitted, op, value); diff --git a/osu.Game/Screens/Select/Footer.cs b/osu.Game/Screens/Select/Footer.cs deleted file mode 100644 index 1d05f644b791..000000000000 --- a/osu.Game/Screens/Select/Footer.cs +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System.Collections.Generic; -using System.Linq; -using osuTK; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Screens.Select -{ - public partial class Footer : Container - { - private readonly Box modeLight; - - public const float HEIGHT = 50; - - public const int TRANSITION_LENGTH = 300; - - private const float padding = 80; - - private readonly FillFlowContainer buttons; - - private readonly List overlays = new List(); - - /// The button to be added. - /// The to be toggled by this button. - public void AddButton(FooterButton button, OverlayContainer overlay) - { - if (overlay != null) - { - overlays.Add(overlay); - button.Action = () => showOverlay(overlay); - } - - button.Hovered = updateModeLight; - button.HoverLost = updateModeLight; - - buttons.Add(button); - } - - private void showOverlay(OverlayContainer overlay) - { - foreach (var o in overlays) - { - if (o == overlay) - o.ToggleVisibility(); - else - o.Hide(); - } - } - - private void updateModeLight() - { - var selectedButton = buttons.FirstOrDefault(b => b.Enabled.Value && b.IsHovered); - - if (selectedButton != null) - { - modeLight.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); - modeLight.FadeColour(selectedButton.SelectedColour, TRANSITION_LENGTH, Easing.OutQuint); - } - else - modeLight.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); - } - - public Footer() - { - RelativeSizeAxes = Axes.X; - Height = HEIGHT; - Anchor = Anchor.BottomCentre; - Origin = Anchor.BottomCentre; - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Size = Vector2.One, - Colour = OsuColour.Gray(0.1f), - Alpha = 0.96f, - }, - modeLight = new Box - { - RelativeSizeAxes = Axes.X, - Height = 3, - Position = new Vector2(0, -3), - Colour = OsuColour.Gray(0.1f), - }, - new FillFlowContainer - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Position = new Vector2(TwoLayerButton.SIZE_EXTENDED.X + padding, 0), - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(padding, 0), - Children = new Drawable[] - { - buttons = new FillFlowContainer - { - Direction = FillDirection.Horizontal, - Spacing = new Vector2(-FooterButton.SHEAR_WIDTH, 0), - AutoSizeAxes = Axes.Both, - } - } - } - }; - - updateModeLight(); - } - - protected override bool OnMouseDown(MouseDownEvent e) => true; - - protected override bool OnClick(ClickEvent e) => true; - - protected override bool OnHover(HoverEvent e) => true; - } -} diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs deleted file mode 100644 index dafa0b0c1c5c..000000000000 --- a/osu.Game/Screens/Select/FooterButton.cs +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -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.Localisation; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Input.Bindings; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Select -{ - public partial class FooterButton : OsuClickableContainer, IKeyBindingHandler - { - public const float SHEAR_WIDTH = 7.5f; - - protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / Footer.HEIGHT, 0); - - /// - /// Used to show an initial animation hinting at the enabled state. - /// - protected virtual bool IsActive => false; - - public LocalisableString Text - { - get => SpriteText?.Text ?? default; - set - { - if (SpriteText != null) - SpriteText.Text = value; - } - } - - private Color4 deselectedColour; - - public Color4 DeselectedColour - { - get => deselectedColour; - set - { - deselectedColour = value; - if (light.Colour != SelectedColour) - light.Colour = value; - } - } - - private Color4 selectedColour; - - public Color4 SelectedColour - { - get => selectedColour; - set - { - selectedColour = value; - box.Colour = selectedColour; - } - } - - protected FillFlowContainer ButtonContentContainer; - protected readonly Container TextContainer; - protected readonly SpriteText SpriteText; - private readonly Box box; - private readonly Box light; - - public FooterButton() - { - AutoSizeAxes = Axes.Both; - Shear = SHEAR; - Children = new Drawable[] - { - box = new Box - { - RelativeSizeAxes = Axes.Both, - EdgeSmoothness = new Vector2(2, 0), - Colour = Color4.White, - Alpha = 0, - }, - light = new Box - { - Height = 4, - EdgeSmoothness = new Vector2(2, 0), - RelativeSizeAxes = Axes.X, - }, - new Container - { - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - ButtonContentContainer = new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Direction = FillDirection.Horizontal, - Shear = -SHEAR, - AutoSizeAxes = Axes.X, - Height = 50, - Spacing = new Vector2(15, 0), - Children = new Drawable[] - { - TextContainer = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Child = SpriteText = new OsuSpriteText - { - AlwaysPresent = true, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } - }, - }, - }, - }, - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - Enabled.BindValueChanged(_ => updateDisplay(), true); - - if (IsActive) - { - box.ClearTransforms(); - - using (box.BeginDelayedSequence(200)) - { - box.FadeIn(200) - .Then() - .FadeOut(1500, Easing.OutQuint); - } - } - } - - public Action Hovered; - public Action HoverLost; - public GlobalAction? Hotkey; - - private bool mouseDown; - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - float horizontalMargin = (100 - TextContainer.Width) / 2; - ButtonContentContainer.Padding = new MarginPadding - { - Left = horizontalMargin, - // right side margin offset to compensate for shear - Right = horizontalMargin - SHEAR_WIDTH / 2 - }; - } - - protected override bool OnHover(HoverEvent e) - { - Hovered?.Invoke(); - updateDisplay(); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - HoverLost?.Invoke(); - updateDisplay(); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - if (!Enabled.Value) - return true; - - mouseDown = true; - updateDisplay(); - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - mouseDown = false; - updateDisplay(); - base.OnMouseUp(e); - } - - protected override bool OnClick(ClickEvent e) - { - if (!Enabled.Value) - return true; - - box.ClearTransforms(); - box.Alpha = 1; - box.FadeOut(Footer.TRANSITION_LENGTH * 3, Easing.OutQuint); - return base.OnClick(e); - } - - public virtual bool OnPressed(KeyBindingPressEvent e) - { - if (e.Action == Hotkey && !e.Repeat) - { - TriggerClick(); - return true; - } - - return false; - } - - public virtual void OnReleased(KeyBindingReleaseEvent e) { } - - private void updateDisplay() - { - this.FadeTo(Enabled.Value ? 1 : 0.25f, Footer.TRANSITION_LENGTH, Easing.OutQuint); - - light.ScaleTo(Enabled.Value && IsHovered ? new Vector2(1, 2) : new Vector2(1), Footer.TRANSITION_LENGTH, Easing.OutQuint); - light.FadeColour(Enabled.Value && IsHovered ? SelectedColour : DeselectedColour, Footer.TRANSITION_LENGTH, Easing.OutQuint); - - box.FadeTo(Enabled.Value & mouseDown ? 0.3f : 0f, Footer.TRANSITION_LENGTH * 2, Easing.OutQuint); - - if (Enabled.Value && IsHovered) - Hovered?.Invoke(); - else - HoverLost?.Invoke(); - } - } -} diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index a15d315f1b5c..81668cc414d3 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -1,143 +1,420 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Screens.Play.HUD; -using osu.Game.Rulesets.Mods; +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; -using osu.Game.Input.Bindings; using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Play.HUD; using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Screens.Select { - public partial class FooterButtonMods : FooterButton, IHasCurrentValue> + public partial class FooterButtonMods : ScreenFooterButton, IHasCurrentValue> { + public Action? RequestDeselectAllMods { get; init; } + + public const float BAR_HEIGHT = 30f; + + private const float mod_display_portion = 0.65f; + + private readonly BindableWithCurrent> current = new BindableWithCurrent>(Array.Empty()); + public Bindable> Current { - get => modDisplay.Current; - set => modDisplay.Current = value; + get => current.Current; + set => current.Current = value; } - protected OsuSpriteText MultiplierText { get; private set; } = null!; - protected Container UnrankedBadge { get; private set; } = null!; + private Container modDisplayBar = null!; - private readonly ModDisplay modDisplay; + private Drawable unrankedBadge = null!; - private ModSettingChangeTracker? modSettingChangeTracker; + private ModDisplay modDisplay = null!; + + private OsuSpriteText multiplierText { get; set; } = null!; + + private Container modContainer = null!; + + private ModCountText overflowModCountDisplay = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; - private Color4 lowMultiplierColour; - private Color4 highMultiplierColour; + [Resolved] + private OsuGameBase game { get; set; } = null!; - public FooterButtonMods() + private IBindable currentLanguage = null!; + + public FooterButtonMods(ModSelectOverlay overlay) + : base(overlay) { - // must be created in ctor for correct operation of `Current`. - modDisplay = new ModDisplay - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(0.8f), - ExpansionMode = ExpansionMode.AlwaysContracted, - }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - SelectedColour = colours.Yellow; - DeselectedColour = SelectedColour.Opacity(0.5f); - lowMultiplierColour = colours.Green; - highMultiplierColour = colours.Red; - Text = @"mods"; - Hotkey = GlobalAction.ToggleModSelection; - - ButtonContentContainer.AddRange(new Drawable[] + Text = SongSelectStrings.Mods; + Icon = FontAwesome.Solid.ExchangeAlt; + AccentColour = colours.Lime1; + + AddRange(new[] { - modDisplay, - MultiplierText = new OsuSpriteText + unrankedBadge = new UnrankedBadge(), + modDisplayBar = new InputBlockingContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - }, - UnrankedBadge = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, + Y = -5f, + Depth = float.MaxValue, + Origin = Anchor.BottomLeft, + Shear = OsuGame.SHEAR, + CornerRadius = CORNER_RADIUS, + Size = new Vector2(BUTTON_WIDTH, BAR_HEIGHT), + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 4, + // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. + Colour = Colour4.Black.Opacity(0.25f), + Offset = new Vector2(0, 2), + }, Children = new Drawable[] { - new Circle + new Box + { + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + new Container { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = colours.Yellow, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, RelativeSizeAxes = Axes.Both, + Width = 1f - mod_display_portion, + Masking = true, + Child = multiplierText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -OsuGame.SHEAR, + UseFullGlyphHeight = false, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold) + } }, - new OsuSpriteText + modContainer = new Container { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = colours.Gray2, - Padding = new MarginPadding(5), - UseFullGlyphHeight = false, - Text = ModSelectOverlayStrings.Unranked.ToLower() - } + CornerRadius = CORNER_RADIUS, + RelativeSizeAxes = Axes.Both, + Width = mod_display_portion, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + modDisplay = new ModDisplay(showExtendedInformation: true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -OsuGame.SHEAR, + Scale = new Vector2(0.5f), + Current = { BindTarget = Current }, + ExpansionMode = ExpansionMode.AlwaysContracted, + }, + overflowModCountDisplay = new ModCountText { Mods = { BindTarget = Current }, }, + } + }, } }, }); } + private ModSettingChangeTracker? modSettingChangeTracker; + protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(mods => + currentLanguage = game.CurrentLanguage.GetBoundCopy(); + currentLanguage.BindValueChanged(_ => ScheduleAfterChildren(updateDisplay)); + + Current.BindValueChanged(m => { modSettingChangeTracker?.Dispose(); - updateMultiplierText(); + updateDisplay(); - if (mods.NewValue != null) + if (m.NewValue != null) { - modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); - modSettingChangeTracker.SettingChanged += _ => updateMultiplierText(); + modSettingChangeTracker = new ModSettingChangeTracker(m.NewValue); + modSettingChangeTracker.SettingChanged += _ => updateDisplay(); } }, true); + + FinishTransforms(true); } - private void updateMultiplierText() => Schedule(() => + protected override bool OnMouseDown(MouseDownEvent e) { + // should probably be OnClick but right mouse button clicks isn't setup well. + if (e.Button == MouseButton.Right) + { + RequestDeselectAllMods?.Invoke(); + return true; + } + + return base.OnMouseDown(e); + } + + private const double duration = 240; + private const Easing easing = Easing.OutQuint; + + private void updateDisplay() + { + if (Current.Value.Count == 0) + { + modDisplayBar.MoveToY(20, duration, easing); + modDisplayBar.FadeOut(duration, easing); + modDisplay.FadeOut(duration, easing); + overflowModCountDisplay.FadeOut(duration, easing); + + unrankedBadge.MoveToY(20, duration, easing); + unrankedBadge.FadeOut(duration, easing); + + // add delay to let unranked indicator hide first before resizing the button back to its original width. + this.Delay(duration).ResizeWidthTo(BUTTON_WIDTH, duration, easing); + } + else + { + if (Current.Value.Any(m => !m.Ranked)) + { + unrankedBadge.MoveToX(0, duration, easing); + unrankedBadge.FadeIn(duration, easing); + + this.ResizeWidthTo(BUTTON_WIDTH + 5 + unrankedBadge.DrawWidth, duration, easing); + } + else + { + unrankedBadge.MoveToX(-unrankedBadge.DrawWidth, duration, easing); + unrankedBadge.FadeOut(duration, easing); + + this.ResizeWidthTo(BUTTON_WIDTH, duration, easing); + } + + modDisplayBar.MoveToY(-5, duration, Easing.OutQuint); + unrankedBadge.MoveToY(-5, duration, easing); + modDisplayBar.FadeIn(duration, easing); + modDisplay.FadeIn(duration, easing); + } + double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1; - MultiplierText.Text = multiplier == 1 ? string.Empty : ModUtils.FormatScoreMultiplier(multiplier); + multiplierText.Text = ModUtils.FormatScoreMultiplier(multiplier); if (multiplier > 1) - MultiplierText.FadeColour(highMultiplierColour, 200); + multiplierText.FadeColour(colours.Red1, duration, easing); else if (multiplier < 1) - MultiplierText.FadeColour(lowMultiplierColour, 200); + multiplierText.FadeColour(colours.Lime1, duration, easing); else - MultiplierText.FadeColour(Color4.White, 200); + multiplierText.FadeColour(Color4.White, duration, easing); + } + + protected override void Update() + { + base.Update(); + + if (Current.Value.Count == 0) + return; - if (Current.Value?.Count > 0) - modDisplay.FadeIn(); + if (modDisplay.DrawWidth * modDisplay.Scale.X > modContainer.DrawWidth) + overflowModCountDisplay.Show(); else - modDisplay.FadeOut(); + overflowModCountDisplay.Hide(); + } + + public partial class ModCountText : VisibilityContainer, IHasCustomTooltip> + { + public readonly Bindable> Mods = new Bindable>(); + + private LocalisableString? customText; + + /// + /// When set, this will be shown instead of a mod count. + /// + public LocalisableString? CustomText + { + get => customText; + set + { + customText = value; + if (IsLoaded) + updateText(); + } + } + + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + Alpha = 0.8f, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), + Shear = -OsuGame.SHEAR, + } + }; + + Mods.BindValueChanged(_ => updateText(), true); + } + + public ITooltip> GetCustomTooltip() => new ModOverflowTooltip(colourProvider); + + public IReadOnlyList? TooltipContent => Mods.Value; + + protected override void PopIn() => this.FadeIn(300, Easing.OutExpo); + protected override void PopOut() => this.FadeOut(300, Easing.OutExpo); + + private void updateText() + { + if (CustomText != null) + text.Text = CustomText.Value; + else + text.Text = ModSelectOverlayStrings.Mods(Mods.Value.Count).ToUpper(); + } + + public partial class ModOverflowTooltip : VisibilityContainer, ITooltip> + { + private ModFlowDisplay extendedModDisplay = null!; + + [Cached] + private OverlayColourProvider colourProvider; - bool anyUnrankedMods = Current.Value?.Any(m => !m.Ranked) == true; - UnrankedBadge.FadeTo(anyUnrankedMods ? 1 : 0); - }); + public ModOverflowTooltip(OverlayColourProvider colourProvider) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + CornerRadius = CORNER_RADIUS; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + extendedModDisplay = new ModFlowDisplay + { + AutoSizeAxes = Axes.Both, + MaximumSize = new Vector2(400, 0), + Margin = new MarginPadding { Vertical = 2f, Horizontal = 10f }, + Scale = new Vector2(0.6f), + }, + }; + } + + public void SetContent(IReadOnlyList content) + { + extendedModDisplay.Current.Value = content; + } + + public void Move(Vector2 pos) => Position = pos; + + protected override void PopIn() => this.FadeIn(240, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(240, Easing.OutQuint); + } + } + + internal partial class UnrankedBadge : InputBlockingContainer, IHasTooltip + { + public LocalisableString TooltipText { get; } + + public UnrankedBadge() + { + Margin = new MarginPadding { Left = BUTTON_WIDTH + 5f }; + Y = -5f; + Depth = float.MaxValue; + Origin = Anchor.BottomLeft; + Shear = OsuGame.SHEAR; + CornerRadius = CORNER_RADIUS; + AutoSizeAxes = Axes.X; + Height = BAR_HEIGHT; + Masking = true; + BorderColour = Color4.White; + BorderThickness = 2f; + TooltipText = ModSelectOverlayStrings.UnrankedExplanation; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Orange2, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -OsuGame.SHEAR, + Text = ModSelectOverlayStrings.Unranked.ToUpper(), + Margin = new MarginPadding { Horizontal = 15 }, + UseFullGlyphHeight = false, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), + Colour = Color4.Black, + } + }; + } + } } } diff --git a/osu.Game/Screens/SelectV2/FooterButtonOptions.Popover.cs b/osu.Game/Screens/Select/FooterButtonOptions.Popover.cs similarity index 97% rename from osu.Game/Screens/SelectV2/FooterButtonOptions.Popover.cs rename to osu.Game/Screens/Select/FooterButtonOptions.Popover.cs index 7e71fedfcb14..d4b7a13d9c20 100644 --- a/osu.Game/Screens/SelectV2/FooterButtonOptions.Popover.cs +++ b/osu.Game/Screens/Select/FooterButtonOptions.Popover.cs @@ -23,7 +23,7 @@ using osuTK.Graphics; using osuTK.Input; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class FooterButtonOptions { @@ -62,7 +62,7 @@ private void load(OsuColour colours) Debug.Assert(beatmap.BeatmapSet != null); addHeader(SongSelectStrings.ForAllDifficulties, beatmap.BeatmapSet.ToString()); - addButton(SongSelectStrings.DeleteBeatmap, FontAwesome.Solid.Trash, () => SongSelect?.Delete(beatmap.BeatmapSet), colours.Red1); + addButton(CommonStrings.DeleteWithConfirmation, FontAwesome.Solid.Trash, () => SongSelect?.Delete(beatmap.BeatmapSet), colours.Red1); addHeader(SongSelectStrings.ForSelectedDifficulty, beatmap.DifficultyName); diff --git a/osu.Game/Screens/Select/FooterButtonOptions.cs b/osu.Game/Screens/Select/FooterButtonOptions.cs index 532051369b97..819b51aea5f9 100644 --- a/osu.Game/Screens/Select/FooterButtonOptions.cs +++ b/osu.Game/Screens/Select/FooterButtonOptions.cs @@ -1,22 +1,66 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Screens.Footer; namespace osu.Game.Screens.Select { - public partial class FooterButtonOptions : FooterButton + public partial class FooterButtonOptions : ScreenFooterButton, IHasPopover { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private IBindable workingBeatmap { get; set; } = null!; + + [Resolved] + private ISongSelect? songSelect { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private Live beatmap = null!; + [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colour) { - SelectedColour = colours.Blue; - DeselectedColour = SelectedColour.Opacity(0.5f); - Text = @"options"; + Text = SongSelectStrings.Options; + Icon = FontAwesome.Solid.Cog; + AccentColour = colour.Purple1; Hotkey = GlobalAction.ToggleBeatmapOptions; + + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + workingBeatmap.BindValueChanged(_ => beatmapChanged(), true); } + + private void beatmapChanged() + { + this.HidePopover(); + Enabled.Value = !workingBeatmap.IsDefault; + if (!workingBeatmap.IsDefault) + beatmap = realm.Run(r => r.Find(workingBeatmap.Value.BeatmapInfo.ID)!.ToLive(realm)); + } + + public Framework.Graphics.UserInterface.Popover GetPopover() => new Popover(this, beatmap.Value.Detach()) + { + ColourProvider = colourProvider, + SongSelect = songSelect + }; } } diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index 2d5d04913337..bf001b7b8f11 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -1,38 +1,38 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Screens.Footer; using osuTK; using osuTK.Input; namespace osu.Game.Screens.Select { - public partial class FooterButtonRandom : FooterButton + public partial class FooterButtonRandom : ScreenFooterButton { - public Action NextRandom { get; set; } - public Action PreviousRandom { get; set; } + public Action? NextRandom { get; set; } + public Action? PreviousRandom { get; set; } - private Container persistentText; - private OsuSpriteText randomSpriteText; - private OsuSpriteText rewindSpriteText; + private Container persistentText = null!; + private OsuSpriteText randomSpriteText = null!; + private OsuSpriteText rewindSpriteText = null!; private bool rewindSearch; [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colour) { - SelectedColour = colours.Green; - DeselectedColour = SelectedColour.Opacity(0.5f); - + //TODO: use https://fontawesome.com/icons/shuffle?s=solid&f=classic when local Fontawesome is updated + Icon = FontAwesome.Solid.Random; + AccentColour = colour.Blue1; TextContainer.Add(persistentText = new Container { Anchor = Anchor.Centre, @@ -43,17 +43,19 @@ private void load(OsuColour colours) { randomSpriteText = new OsuSpriteText { + Font = OsuFont.TorusAlternate.With(size: 16), AlwaysPresent = true, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "random", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = SongSelectStrings.Random, }, rewindSpriteText = new OsuSpriteText { + Font = OsuFont.TorusAlternate.With(size: 16), AlwaysPresent = true, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "rewind", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = SongSelectStrings.Rewind, Alpha = 0f, } } @@ -72,8 +74,9 @@ private void load(OsuColour colours) Alpha = 0, Text = rewindSpriteText.Text, AlwaysPresent = true, // make sure the button is sized large enough to always show this - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.TorusAlternate.With(size: 16), }); fallingRewind.FadeOutFromOne(fade_time, Easing.In); @@ -82,33 +85,27 @@ private void load(OsuColour colours) persistentText.FadeInFromZero(fade_time, Easing.In); - PreviousRandom.Invoke(); + PreviousRandom?.Invoke(); } else { - NextRandom.Invoke(); + NextRandom?.Invoke(); } }; } protected override bool OnKeyDown(KeyDownEvent e) { - updateText(e); + updateText(e.ShiftPressed); return base.OnKeyDown(e); } protected override void OnKeyUp(KeyUpEvent e) { - updateText(e); + updateText(e.ShiftPressed); base.OnKeyUp(e); } - protected override bool OnMouseDown(MouseDownEvent e) - { - updateText(e); - return base.OnMouseDown(e); - } - protected override bool OnClick(ClickEvent e) { try @@ -125,15 +122,14 @@ protected override bool OnClick(ClickEvent e) protected override void OnMouseUp(MouseUpEvent e) { - base.OnMouseUp(e); - if (e.Button == MouseButton.Right && IsHovered) { rewindSearch = true; TriggerClick(); + return; } - updateText(e); + base.OnMouseUp(e); } public override bool OnPressed(KeyBindingPressEvent e) @@ -158,12 +154,10 @@ public override void OnReleased(KeyBindingReleaseEvent e) } } - private void updateText(UIEvent e) + private void updateText(bool rewind = false) { - bool aboutToRewind = e.ShiftPressed || e.CurrentState.Mouse.IsPressed(MouseButton.Right); - - randomSpriteText.Alpha = aboutToRewind ? 0 : 1; - rewindSpriteText.Alpha = aboutToRewind ? 1 : 0; + randomSpriteText.Alpha = rewind ? 0 : 1; + rewindSpriteText.Alpha = rewind ? 1 : 0; } } } diff --git a/osu.Game/Screens/SelectV2/ISongSelect.cs b/osu.Game/Screens/Select/ISongSelect.cs similarity index 54% rename from osu.Game/Screens/SelectV2/ISongSelect.cs rename to osu.Game/Screens/Select/ISongSelect.cs index e39f74c01876..d63f241c80ce 100644 --- a/osu.Game/Screens/SelectV2/ISongSelect.cs +++ b/osu.Game/Screens/Select/ISongSelect.cs @@ -2,15 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Scoring; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { /// /// Actions exposed by song select which are used by subcomponents to perform top-level operations. /// + [Cached] public interface ISongSelect { /// @@ -28,11 +31,17 @@ public interface ISongSelect /// void ManageCollections(); + /// + /// Whether can be performed by this screen. + /// If , will have no effect. + /// + bool CanPresentScore { get; } + /// /// Opens results screen with the given score. /// This assumes active beatmap and ruleset selection matches the score. /// - void PresentScore(ScoreInfo score); + void PresentScore(ScoreInfo score, ScorePresentType presentType = ScorePresentType.Results); /// /// Set the current filter text query to the provided string. @@ -43,5 +52,23 @@ public interface ISongSelect /// Gets relevant actionable items for beatmap context menus, based on the type of song select. /// IEnumerable GetForwardActions(BeatmapInfo beatmap); + + /// + /// Temporarily bypasses filters and shows all difficulties of the given beatmapset. + /// + /// The beatmapset. + void ScopeToBeatmapSet(BeatmapSetInfo beatmapSet); + + /// + /// Removes the beatmapset scope and reverts the previously selected filters. + /// + void UnscopeBeatmapSet(); + + /// + /// Contains the currently scoped beatmapset. Used by external consumers for displaying its state. + /// Cannot be used to change the value, any changes must be done through + /// or . + /// + IBindable ScopedBeatmapSet { get; } } } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs deleted file mode 100644 index ddb7814d1293..000000000000 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Online.Leaderboards; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Scoring; - -namespace osu.Game.Screens.Select.Leaderboards -{ - public partial class BeatmapLeaderboard : Leaderboard - { - public Action? ScoreSelected; - - private BeatmapInfo? beatmapInfo; - - public BeatmapInfo? BeatmapInfo - { - get => beatmapInfo; - set - { - if (beatmapInfo == null && value == null) - return; - - if (beatmapInfo?.Equals(value) == true) - return; - - beatmapInfo = value; - - // Refetch is scheduled, which can cause scores to be outdated if the leaderboard is not currently updating. - // As scores are potentially used by other components, clear them eagerly to ensure a more correct state. - SetScores(null); - - RefetchScores(); - } - } - - private bool filterMods; - - /// - /// Whether to apply the game's currently selected mods as a filter when retrieving scores. - /// - public bool FilterMods - { - get => filterMods; - set - { - if (value == filterMods) - return; - - filterMods = value; - - RefetchScores(); - } - } - - private readonly IBindable fetchedScores = new Bindable(); - - [Resolved] - private IBindable ruleset { get; set; } = null!; - - [Resolved] - private IBindable> mods { get; set; } = null!; - - [Resolved] - private LeaderboardManager leaderboardManager { get; set; } = null!; - - [BackgroundDependencyLoader] - private void load() - { - ruleset.ValueChanged += _ => RefetchScores(); - mods.ValueChanged += _ => - { - if (filterMods) - RefetchScores(); - }; - } - - private bool initialFetchComplete; - - protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; - - protected override APIRequest? FetchScores(CancellationToken cancellationToken) - { - var fetchBeatmapInfo = BeatmapInfo; - var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo?.Ruleset; - - // Without this check, an initial fetch will be performed and clear global cache. - if (fetchBeatmapInfo == null) - return null; - - // For now, we forcefully refresh to keep things simple. - // In the future, removing this requirement may be deemed useful, but will need ample testing of edge case scenarios - // (like returning from gameplay after setting a new score, returning to song select after main menu). - leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.Where(m => m.UserPlayable).ToArray() : null), forceRefresh: true); - - if (!initialFetchComplete) - { - // only bind this after the first fetch to avoid reading stale scores. - fetchedScores.BindTo(leaderboardManager.Scores); - fetchedScores.BindValueChanged(_ => updateScores(), true); - initialFetchComplete = true; - } - - return null; - } - - private void updateScores() - { - var scores = fetchedScores.Value; - - if (scores == null) return; - - if (scores.FailState == null) - Schedule(() => SetScores(scores.TopScores, scores.UserScore)); - else - Schedule(() => SetErrorState((LeaderboardState)scores.FailState)); - } - - protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope, Scope != BeatmapLeaderboardScope.Friend) - { - Action = () => ScoreSelected?.Invoke(model) - }; - - protected override LeaderboardScore CreateDrawableTopScore(ScoreInfo model) => new LeaderboardScore(model, model.Position, false, Scope != BeatmapLeaderboardScope.Friend) - { - Action = () => ScoreSelected?.Invoke(model) - }; - } -} diff --git a/osu.Game/Screens/Select/LeftSideInteractionContainer.cs b/osu.Game/Screens/Select/LeftSideInteractionContainer.cs new file mode 100644 index 000000000000..4b28213f4ecb --- /dev/null +++ b/osu.Game/Screens/Select/LeftSideInteractionContainer.cs @@ -0,0 +1,60 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.Events; + +namespace osu.Game.Screens.Select +{ + /// + /// Handles mouse interactions required when moving away from the carousel. + /// + internal partial class LeftSideInteractionContainer : Container + { + private readonly Action? resetCarouselPosition; + + private bool mouseContained; + + private InputManager inputManager = null!; + + public LeftSideInteractionContainer(Action resetCarouselPosition) + { + this.resetCarouselPosition = resetCarouselPosition; + } + + // we want to block plain scrolls on the left side so that they don't scroll the carousel, + // but also we *don't* want to handle scrolls when they're combined with keyboard modifiers + // as those will usually correspond to other interactions like adjusting volume. + protected override bool OnScroll(ScrollEvent e) => !e.ControlPressed && !e.AltPressed && !e.ShiftPressed && !e.SuperPressed; + + protected override bool OnMouseDown(MouseDownEvent e) => true; + + protected override void LoadComplete() + { + inputManager = GetContainingInputManager()!; + base.LoadComplete(); + } + + protected override void Update() + { + base.Update(); + + // We want to trigger an action whenever the cursor is in the left area of song select. + // Other elements in song select handle input, so rather than using `OnHover` let's check the true mouse position. + if (Contains(inputManager.CurrentState.Mouse.Position)) + { + if (!mouseContained) + { + mouseContained = true; + resetCarouselPosition?.Invoke(); + } + } + else + { + mouseContained = false; + } + } + } +} diff --git a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs index ec2b8437e1aa..4127e474f884 100644 --- a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs +++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Game.Overlays.Dialog; using osu.Game.Scoring; @@ -19,7 +20,7 @@ public LocalScoreDeleteDialog(ScoreInfo score) [BackgroundDependencyLoader] private void load(ScoreManager scoreManager) { - BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank})"; + BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank.GetLocalisableDescription()})"; DangerousAction = () => scoreManager.Delete(score); } } diff --git a/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs index 998f94849c63..6d9f58bcfda8 100644 --- a/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs +++ b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Configuration; -using osu.Game.Input; using osu.Game.Overlays; using osu.Game.Overlays.OSD; using osu.Game.Rulesets.Mods; @@ -21,9 +20,6 @@ public partial class ModSpeedHotkeyHandler : Component [Resolved] private Bindable> selectedMods { get; set; } = null!; - [Resolved] - private RealmKeyBindingStore keyBindingStore { get; set; } = null!; - [Resolved] private OnScreenDisplay? onScreenDisplay { get; set; } @@ -56,7 +52,7 @@ public bool ChangeSpeed(double delta, IEnumerable availableMods) if (Precision.AlmostEquals(targetSpeed, 1, 0.005)) { selectedMods.Value = selectedMods.Value.Where(m => m is not ModRateAdjust).ToList(); - onScreenDisplay?.Display(new SpeedChangeToast(keyBindingStore, targetSpeed)); + onScreenDisplay?.Display(new SpeedChangeToast(targetSpeed)); return true; } @@ -109,7 +105,7 @@ public bool ChangeSpeed(double delta, IEnumerable availableMods) return false; selectedMods.Value = intendedMods; - onScreenDisplay?.Display(new SpeedChangeToast(keyBindingStore, targetMod.SpeedChange.Value)); + onScreenDisplay?.Display(new SpeedChangeToast(targetMod.SpeedChange.Value)); return true; } } diff --git a/osu.Game/Screens/Select/NoResultsPlaceholder.cs b/osu.Game/Screens/Select/NoResultsPlaceholder.cs index 50577d5feace..cd38ce2f6c86 100644 --- a/osu.Game/Screens/Select/NoResultsPlaceholder.cs +++ b/osu.Game/Screens/Select/NoResultsPlaceholder.cs @@ -1,15 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Localisation; using osu.Game.Online.Chat; using osu.Game.Overlays; @@ -19,10 +20,14 @@ namespace osu.Game.Screens.Select { public partial class NoResultsPlaceholder : VisibilityContainer { + public Action? RequestClearFilterText { get; init; } + private FilterCriteria? filter; private LinkFlowContainer textFlow = null!; + private GhostIcon icon = null!; + [Resolved] private BeatmapManager beatmaps { get; set; } = null!; @@ -32,6 +37,8 @@ public partial class NoResultsPlaceholder : VisibilityContainer [Resolved] private OsuConfigManager config { get; set; } = null!; + protected override bool StartHidden => true; + public FilterCriteria Filter { set @@ -45,43 +52,68 @@ public FilterCriteria Filter } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - Masking = true; - CornerRadius = 10; - - Width = 400; - AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.Both; Anchor = Anchor.Centre; Origin = Anchor.Centre; InternalChildren = new Drawable[] { - new Box - { - Colour = colours.Gray2, - RelativeSizeAxes = Axes.Both, - }, - new SpriteIcon - { - Icon = FontAwesome.Regular.SadTear, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding(10), - Size = new Vector2(50), - }, - textFlow = new LinkFlowContainer + new FillFlowContainer { - Y = 60, - Padding = new MarginPadding(10), - TextAnchor = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, AutoSizeAxes = Axes.Y, - } + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding(10), + Size = new Vector2(50), + Child = icon = new GhostIcon + { + RelativeSizeAxes = Axes.Both, + }, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Style.Title, + Text = SongSelectStrings.NoMatchingBeatmaps + }, + textFlow = new LinkFlowContainer + { + Alpha = 0, + AlwaysPresent = true, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Padding = new MarginPadding { Top = 20 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + }, }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + icon.Loop(t => + t.MoveToY(-10, 2000, Easing.InOutSine) + .Then() + .MoveToY(0, 2000, Easing.InOutSine) + ); + } + protected override void PopIn() { this.FadeIn(600, Easing.OutQuint); @@ -100,27 +132,40 @@ private void updateText() // Bounce should play every time the filter criteria is updated. this.ScaleTo(0.9f) - .ScaleTo(1f, 1000, Easing.OutElastic); + .ScaleTo(1f, 1000, Easing.OutQuint); + + textFlow.FadeInFromZero(800, Easing.OutQuint); textFlow.Clear(); if (beatmaps.QueryBeatmapSet(s => !s.Protected && !s.DeletePending) == null) { - textFlow.AddParagraph("No beatmaps found!"); - textFlow.AddParagraph(string.Empty); - - textFlow.AddParagraph("- Consider running the \""); + addBulletPoint(); + textFlow.AddText("Consider running the \""); textFlow.AddLink(FirstRunSetupOverlayStrings.FirstRunSetupTitle, () => firstRunSetupOverlay?.Show()); textFlow.AddText("\" to download or import some beatmaps!"); } else { - textFlow.AddParagraph("No beatmaps match your filter criteria!"); + textFlow.AddParagraph(SongSelectStrings.NoMatchingBeatmapsDescription); textFlow.AddParagraph(string.Empty); + if (!string.IsNullOrEmpty(filter?.SearchText)) + { + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("clearing", () => + { + RequestClearFilterText?.Invoke(); + }); + + textFlow.AddText(" your current search criteria."); + } + if (filter?.UserStarDifficulty.HasFilter == true) { - textFlow.AddParagraph("- Try "); + addBulletPoint(); + textFlow.AddText("Try "); textFlow.AddLink("removing", () => { config.SetValue(OsuSetting.DisplayStarsMinimum, 0.0); @@ -137,19 +182,31 @@ private void updateText() // TODO: Make this message more certain by ensuring the osu! beatmaps exist before suggesting. if (filter?.Ruleset?.OnlineID != 0 && filter?.AllowConvertedBeatmaps == false) { - textFlow.AddParagraph("- Try "); - textFlow.AddLink("enabling ", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - textFlow.AddText("automatic conversion!"); + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("enabling", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + textFlow.AddText(" automatic conversion!"); } } if (!string.IsNullOrEmpty(filter?.SearchText)) { - textFlow.AddParagraph("- Try "); + addBulletPoint(); + textFlow.AddText("Try "); textFlow.AddLink("searching online", LinkAction.SearchBeatmapSet, filter.SearchText); textFlow.AddText($" for \"{filter.SearchText}\"."); } // TODO: add clickable link to reset criteria. } + + private void addBulletPoint() + { + textFlow.NewLine(); + textFlow.AddIcon(FontAwesome.Solid.Circle, i => + { + i.Padding = new MarginPadding { Top = 24, Right = 15 }; + i.Scale *= 0.3f; + }); + } } } diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs deleted file mode 100644 index 572b2427b178..000000000000 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Screens.Select.Options -{ - public partial class BeatmapOptionsButton : OsuClickableContainer - { - private const float width = 130; - - private readonly Box background; - private readonly Box flash; - private readonly SpriteIcon iconText; - private readonly OsuSpriteText firstLine; - private readonly OsuSpriteText secondLine; - private readonly Container box; - - public Color4 ButtonColour - { - get => background.Colour; - set => background.Colour = value; - } - - public IconUsage Icon - { - get => iconText.Icon; - set => iconText.Icon = value; - } - - public LocalisableString FirstLineText - { - get => firstLine.Text; - set => firstLine.Text = value; - } - - public LocalisableString SecondLineText - { - get => secondLine.Text; - set => secondLine.Text = value; - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - flash.FadeTo(0.1f, 1000, Easing.OutQuint); - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - flash.FadeTo(0, 1000, Easing.OutQuint); - base.OnMouseUp(e); - } - - protected override bool OnClick(ClickEvent e) - { - flash.ClearTransforms(); - flash.Alpha = 0.9f; - flash.FadeOut(800, Easing.OutExpo); - - return base.OnClick(e); - } - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); - - public BeatmapOptionsButton() - : base(HoverSampleSet.Button) - { - Width = width; - RelativeSizeAxes = Axes.Y; - - Children = new Drawable[] - { - box = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Shear = OsuGame.SHEAR, - Masking = true, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.2f), - Roundness = 5, - Radius = 8, - }, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - EdgeSmoothness = new Vector2(1.5f, 0), - Colour = Color4.Black, - }, - flash = new Box - { - RelativeSizeAxes = Axes.Both, - EdgeSmoothness = new Vector2(1.5f, 0), - Blending = BlendingParameters.Additive, - Colour = Color4.White, - Alpha = 0, - }, - }, - }, - new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - iconText = new SpriteIcon - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Size = new Vector2(30), - Shadow = true, - Icon = FontAwesome.Solid.TimesCircle, - Margin = new MarginPadding - { - Bottom = 5, - }, - }, - firstLine = new OsuSpriteText - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = @"", - }, - secondLine = new OsuSpriteText - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = @"", - }, - }, - }, - }; - } - } -} diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs deleted file mode 100644 index 7b631ebfea8c..000000000000 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; -using osuTK.Input; -using osu.Game.Graphics.Containers; -using osu.Framework.Input.Events; -using System.Linq; -using osu.Framework.Localisation; - -namespace osu.Game.Screens.Select.Options -{ - public partial class BeatmapOptionsOverlay : OsuFocusedOverlayContainer - { - private const float transition_duration = 500; - private const float x_position = 0.2f; - private const float x_movement = 0.8f; - - private const float height = 100; - - private readonly Box holder; - private readonly FillFlowContainer buttonsContainer; - - public override bool BlockScreenWideMouse => false; - - protected override string PopInSampleName => "SongSelect/options-pop-in"; - protected override string PopOutSampleName => "SongSelect/options-pop-out"; - - public BeatmapOptionsOverlay() - { - AutoSizeAxes = Axes.Y; - RelativeSizeAxes = Axes.X; - Anchor = Anchor.BottomLeft; - Origin = Anchor.BottomLeft; - - Children = new Drawable[] - { - holder = new Box - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - Scale = new Vector2(1, 0), - Colour = Color4.Black.Opacity(0.5f), - }, - buttonsContainer = new ReverseChildIDFillFlowContainer - { - Height = height, - RelativePositionAxes = Axes.X, - AutoSizeAxes = Axes.X, - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - }, - }; - } - - /// Text in the first line. - /// Text in the second line. - /// Colour of the button. - /// Icon of the button. - /// Binding the button does. - public void AddButton(LocalisableString firstLine, string secondLine, IconUsage icon, Color4 colour, Action action) - { - var button = new BeatmapOptionsButton - { - FirstLineText = firstLine, - SecondLineText = secondLine, - Icon = icon, - ButtonColour = colour, - Action = () => - { - Hide(); - action?.Invoke(); - }, - }; - - buttonsContainer.Add(button); - } - - protected override void PopIn() - { - this.FadeIn(transition_duration, Easing.OutQuint); - - if (buttonsContainer.Position.X == 1 || Alpha == 0) - buttonsContainer.MoveToX(x_position - x_movement); - - holder.ScaleTo(new Vector2(1, 1), transition_duration / 2, Easing.OutQuint); - - buttonsContainer.MoveToX(x_position, transition_duration, Easing.OutQuint); - buttonsContainer.TransformSpacingTo(Vector2.Zero, transition_duration, Easing.OutQuint); - } - - protected override void PopOut() - { - base.PopOut(); - - holder.ScaleTo(new Vector2(1, 0), transition_duration / 2, Easing.InSine); - - buttonsContainer.MoveToX(x_position + x_movement, transition_duration, Easing.InSine); - buttonsContainer.TransformSpacingTo(new Vector2(200f, 0f), transition_duration, Easing.InSine); - - this.FadeOut(transition_duration, Easing.InQuint); - } - - protected override bool OnKeyDown(KeyDownEvent e) - { - // don't absorb control as ToolbarRulesetSelector uses control + number to navigate - if (e.ControlPressed) return false; - - if (!e.Repeat && e.Key >= Key.Number1 && e.Key <= Key.Number9) - { - int requested = e.Key - Key.Number1; - - // go reverse as buttonsContainer is a ReverseChildIDFillFlowContainer - BeatmapOptionsButton found = buttonsContainer.Children.ElementAtOrDefault((buttonsContainer.Children.Count - 1) - requested); - - if (found != null) - { - found.TriggerClick(); - return true; - } - } - - return base.OnKeyDown(e); - } - } -} diff --git a/osu.Game/Screens/SelectV2/Panel.cs b/osu.Game/Screens/Select/Panel.cs similarity index 95% rename from osu.Game/Screens/SelectV2/Panel.cs rename to osu.Game/Screens/Select/Panel.cs index 241002fa76a8..a02710b0a6ef 100644 --- a/osu.Game/Screens/SelectV2/Panel.cs +++ b/osu.Game/Screens/Select/Panel.cs @@ -24,7 +24,7 @@ using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public abstract partial class Panel : PoolableDrawable, ICarouselPanel, IHasContextMenu { @@ -265,7 +265,12 @@ protected override void FreeAfterUse() protected override bool OnClick(ClickEvent e) { - carousel?.Activate(Item!); + // Item may be set to null before actual `FreeAfterUse`. + // This is because Carousel knows to do this ahead of time and let the drawable fade/animate away. + // See https://github.com/ppy/osu/blob/033e13cb3b79e6195ddcd9f659b04095aa52fd2f/osu.Game/Graphics/Carousel/Carousel.cs#L1132-L1135. + if (item != null) + carousel?.Activate(item); + return true; } @@ -362,7 +367,8 @@ public CarouselItem? Item if (ReferenceEquals(item, value)) return; - // If a new item is set and we already have an item, this is a case of reuse. + // If a new item is set and we already have an item, this is a special case of reuse. + // See https://github.com/ppy/osu/blob/033e13cb3b79e6195ddcd9f659b04095aa52fd2f/osu.Game/Graphics/Carousel/Carousel.cs#L1071 // To keep things simple, assume that we need to do a full refresh. // // In the future, this could be more contextual and check whether the associated model has actually changed. diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/Select/PanelBeatmap.cs similarity index 97% rename from osu.Game/Screens/SelectV2/PanelBeatmap.cs rename to osu.Game/Screens/Select/PanelBeatmap.cs index 59603e145d7c..4c259a63550a 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/Select/PanelBeatmap.cs @@ -27,7 +27,7 @@ using osu.Game.Rulesets.Mods; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelBeatmap : Panel { @@ -53,12 +53,6 @@ public partial class PanelBeatmap : Panel [Resolved] private IRulesetStore rulesets { get; set; } = null!; - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - [Resolved] - private OsuColour colours { get; set; } = null!; - [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } = null!; @@ -278,10 +272,13 @@ protected override void Update() backgroundBorder.Colour = diffColour; backgroundDifficultyTint.Colour = ColourInfo.GradientHorizontal(diffColour.Opacity(0.25f), diffColour.Opacity(0f)); - difficultyIcon.Colour = starRatingDisplay.DisplayedStars.Value > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5; - triangles.Colour = ColourInfo.GradientVertical(diffColour.Opacity(0.25f), diffColour.Opacity(0f)); } + + if (difficultyIcon.Colour != starRatingDisplay.DisplayedDifficultyTextColour) + { + difficultyIcon.Colour = starRatingDisplay.DisplayedDifficultyTextColour; + } } private void updateKeyCount() diff --git a/osu.Game/Screens/Select/PanelBeatmapSet.SpreadDisplay.cs b/osu.Game/Screens/Select/PanelBeatmapSet.SpreadDisplay.cs new file mode 100644 index 000000000000..338b4036bd24 --- /dev/null +++ b/osu.Game/Screens/Select/PanelBeatmapSet.SpreadDisplay.cs @@ -0,0 +1,269 @@ +// 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.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osuTK; + +namespace osu.Game.Screens.Select +{ + public partial class PanelBeatmapSet + { + public partial class SpreadDisplay : OsuAnimatedButton + { + public Bindable BeatmapSet { get; } = new Bindable(); + + public IBindable?> VisibleBeatmaps { get; } = new Bindable?>(); + + public BindableBool Expanded { get; } = new BindableBool(); + + protected override Colour4 DimColour => Colour4.White; + + private readonly IBindable scopedBeatmapSet = new Bindable(); + private readonly Bindable showConvertedBeatmaps = new Bindable(); + + private const double transition_duration = 200; + + [Resolved] + private ISongSelect? songSelect { get; set; } + + [Resolved] + private Bindable ruleset { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private FillFlowContainer flow = null!; + private SpriteIcon icon = null!; + + public SpreadDisplay() + { + AutoSizeAxes = Axes.X; + Height = 14; + Content.CornerRadius = 5; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Padding = new MarginPadding { Horizontal = 5 }, + Children = new Drawable[] + { + flow = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1), + }, + icon = new SpriteIcon + { + Size = new Vector2(12), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.Eye, + Alpha = 0, + } + } + }); + + if (songSelect != null) + scopedBeatmapSet.BindTo(songSelect.ScopedBeatmapSet); + + configManager.BindWith(OsuSetting.ShowConvertedBeatmaps, showConvertedBeatmaps); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + BeatmapSet.BindValueChanged(_ => updateBeatmapSet()); + VisibleBeatmaps.BindValueChanged(_ => updateBeatmapSet()); + showConvertedBeatmaps.BindValueChanged(_ => updateBeatmapSet(), true); + Expanded.BindValueChanged(_ => updateEnabled()); + scopedBeatmapSet.BindValueChanged(_ => updateEnabled(), true); + scopedBeatmapSet.BindDisabledChanged(_ => updateEnabled(), true); + Enabled.BindValueChanged(_ => updateAppearance(), true); + FinishTransforms(true); + } + + private void updateBeatmapSet() + { + if (BeatmapSet.Value == null) + { + this.FadeOut(transition_duration, Easing.OutQuint); + return; + } + + flow.Clear(); + + const int max_difficulties_before_collapsing = 12; + + var beatmaps = BeatmapSet.Value.Beatmaps + .Where(b => b.AllowGameplayWithRuleset(ruleset.Value, showConvertedBeatmaps.Value)) + .ToList(); + this.FadeTo(beatmaps.Count > 0 ? 1 : 0, transition_duration, Easing.OutQuint); + + if (beatmaps.Count == 0) + return; + + bool showVisible = VisibleBeatmaps.Value == null || VisibleBeatmaps.Value?.Count <= max_difficulties_before_collapsing; + bool showHidden = beatmaps.Count <= max_difficulties_before_collapsing; + + var beatmapsByRuleset = beatmaps.GroupBy(beatmap => beatmap.Ruleset.OnlineID).OrderBy(group => group.Key); + + foreach (var rulesetGrouping in beatmapsByRuleset) + { + int rulesetId = rulesetGrouping.Key; + var rulesetIcon = rulesets.GetRuleset(rulesetId)?.CreateInstance().CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }; + flow.Add(rulesetIcon.With(i => + { + i.Size = new Vector2(14); + i.Anchor = i.Origin = Anchor.CentreLeft; + i.Margin = new MarginPadding { Left = flow.Count > 0 ? 9 : 0 }; + })); + + int overflowVisible = 0; + int overflowHidden = 0; + bool? lastBeatmapVisible = null; + + foreach (var beatmap in rulesetGrouping.OrderBy(beatmap => beatmap.StarRating)) + { + bool visible = VisibleBeatmaps.Value?.Contains(beatmap) != false; + + if ((visible && showVisible) || (!visible && showHidden)) + { + var circle = new Circle + { + Size = visible ? new Vector2(7, 12) : new Vector2(5, 10), + Alpha = visible ? 1 : 0.5f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = colours.ForStarDifficulty(beatmap.StarRating), + Margin = new MarginPadding { Left = lastBeatmapVisible != null && lastBeatmapVisible != visible ? 1 : 0 } + }; + flow.Add(circle); + + lastBeatmapVisible = visible; + } + else + { + if (visible) + overflowVisible++; + else + overflowHidden++; + } + } + + if (overflowVisible > 0) + { + flow.Add(new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Caption2, + Text = overflowVisible.ToLocalisableString(), + }); + } + + if (overflowHidden > 0) + { + flow.Add(new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Caption2, + Text = LocalisableString.Interpolate($@"+{overflowHidden}"), + Alpha = 0.7f, + }); + } + } + + Action = () => songSelect?.ScopeToBeatmapSet(BeatmapSet.Value); + updateEnabled(); + } + + private void updateEnabled() + { + Enabled.Value = Expanded.Value && !scopedBeatmapSet.Disabled && scopedBeatmapSet.Value == null; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (!Enabled.Value) + return false; + + base.OnMouseDown(e); + return true; + } + + protected override bool OnClick(ClickEvent e) + { + if (!Enabled.Value) + return false; + + // this is a crude workaround with an issue with `OsuAnimatedButton` that isn't easily fixable. + // the issue is that when wanting to turn off the hover layer upon click, `HoverColour` can be set to a transparent colour, + // *but* this has to happen *before* `base.OnClick()`. + // this is because `base.OnClick()` uses `FlashColour()` to flash the button on click, + // but that `FlashColour()` call implicitly copies `hoverColour` *at the point of call* into the transform that ends the flash. + updateAppearance(false); + return base.OnClick(e); + } + + protected override bool OnHover(HoverEvent e) + { + updateAppearance(); + + if (!Enabled.Value) + return false; + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateAppearance(); + + if (!Enabled.Value) + return; + + base.OnHoverLost(e); + } + + private void updateAppearance(bool? isInteractable = null) + { + isInteractable ??= Enabled.Value && IsHovered; + + HoverColour = isInteractable.Value ? Colour4.White.Opacity(0.1f) : Colour4.Transparent; + icon.FadeTo(isInteractable.Value ? 1 : 0, transition_duration, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs b/osu.Game/Screens/Select/PanelBeatmapSet.cs similarity index 93% rename from osu.Game/Screens/SelectV2/PanelBeatmapSet.cs rename to osu.Game/Screens/Select/PanelBeatmapSet.cs index 792fa90c4eb3..5a9508a82383 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs +++ b/osu.Game/Screens/Select/PanelBeatmapSet.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; @@ -31,12 +32,14 @@ using osuTK.Graphics; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelBeatmapSet : Panel { public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.6f; + public Bindable?> VisibleBeatmaps { get; } = new Bindable?>(); + private Box chevronBackground = null!; private PanelSetBackground setBackground = null!; private ScheduledDelegate? scheduledBackgroundRetrieval; @@ -46,7 +49,7 @@ public partial class PanelBeatmapSet : Panel private Drawable chevronIcon = null!; private PanelUpdateBeatmapButton updateButton = null!; private BeatmapSetOnlineStatusPill statusPill = null!; - private DifficultySpectrumDisplay difficultiesDisplay = null!; + private SpreadDisplay spreadDisplay = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -150,10 +153,11 @@ private void load() Origin = Anchor.CentreLeft, Margin = new MarginPadding { Right = 5f, Top = -2f }, }, - difficultiesDisplay = new DifficultySpectrumDisplay + spreadDisplay = new SpreadDisplay { - Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + VisibleBeatmaps = { BindTarget = VisibleBeatmaps }, }, }, } @@ -184,6 +188,8 @@ private void onExpanded() chevronIcon.ResizeWidthTo(0f, DURATION, Easing.OutQuint); chevronIcon.FadeTo(0f, DURATION, Easing.OutQuint); } + + spreadDisplay.Expanded.Value = Expanded.Value; } protected override void PrepareForUse() @@ -199,7 +205,7 @@ protected override void PrepareForUse() artistText.Text = new RomanisableString(beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist); updateButton.BeatmapSet = beatmapSet; statusPill.Status = beatmapSet.Status; - difficultiesDisplay.BeatmapSet = beatmapSet; + spreadDisplay.BeatmapSet.Value = beatmapSet; } protected override void FreeAfterUse() @@ -210,7 +216,7 @@ protected override void FreeAfterUse() scheduledBackgroundRetrieval = null; setBackground.Beatmap = null; updateButton.BeatmapSet = null; - difficultiesDisplay.BeatmapSet = null; + spreadDisplay.BeatmapSet.Value = null; } [Resolved] @@ -272,7 +278,7 @@ public override MenuItem[] ContextMenuItems if (beatmapSet.Beatmaps.Any(b => b.Hidden)) items.Add(new OsuMenuItem(SongSelectStrings.RestoreAllHidden, MenuItemType.Standard, () => songSelect?.RestoreAllHidden(beatmapSet))); - items.Add(new OsuMenuItem(SongSelectStrings.DeleteBeatmap, MenuItemType.Destructive, () => songSelect?.Delete(beatmapSet))); + items.Add(new OsuMenuItem(CommonStrings.DeleteWithConfirmation, MenuItemType.Destructive, () => songSelect?.Delete(beatmapSet))); return items.ToArray(); } } @@ -296,7 +302,7 @@ private MenuItem createCollectionMenuItem(BeatmapCollection collection) return new TernaryStateToggleMenuItem(collection.Name, MenuItemType.Standard, s => { - liveCollection.PerformWrite(c => + Task.Run(() => liveCollection.PerformWrite(c => { foreach (var b in beatmapSet.Beatmaps) { @@ -314,7 +320,7 @@ private MenuItem createCollectionMenuItem(BeatmapCollection collection) break; } } - }); + })); }) { State = { Value = state } diff --git a/osu.Game/Screens/Select/PanelBeatmapStandalone.SpreadDisplay.cs b/osu.Game/Screens/Select/PanelBeatmapStandalone.SpreadDisplay.cs new file mode 100644 index 000000000000..1e4efe495c97 --- /dev/null +++ b/osu.Game/Screens/Select/PanelBeatmapStandalone.SpreadDisplay.cs @@ -0,0 +1,268 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osuTK; + +namespace osu.Game.Screens.Select +{ + public partial class PanelBeatmapStandalone + { + public partial class SpreadDisplay : OsuAnimatedButton + { + public Bindable Beatmap { get; } = new Bindable(); + public Bindable StarDifficulty { get; } = new Bindable(); + + protected override Colour4 DimColour => Colour4.White; + + private readonly IBindable scopedBeatmapSet = new Bindable(); + private readonly Bindable showConvertedBeatmaps = new Bindable(); + + private const double transition_duration = 200; + + [Resolved] + private ISongSelect? songSelect { get; set; } + + [Resolved] + private Bindable ruleset { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private FillFlowContainer preceding = null!; + public Circle Current { get; private set; } = null!; + private FillFlowContainer succeeding = null!; + + private OsuSpriteText countText = null!; + private SpriteIcon icon = null!; + + public SpreadDisplay() + { + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + Content.CornerRadius = 5; + + Action = () => + { + if (Beatmap.Value != null) + songSelect?.ScopeToBeatmapSet(Beatmap.Value.BeatmapSet!); + }; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Padding = new MarginPadding { Horizontal = 5 }, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2), + Children = new Drawable[] + { + preceding = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1), + Alpha = 0.5f, + }, + Current = new Circle + { + Size = new Vector2(7, 12), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + succeeding = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1), + Alpha = 0.5f, + } + } + }, + countText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Caption2, + }, + icon = new SpriteIcon + { + Size = new Vector2(12), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.Eye, + Alpha = 0, + } + } + }); + + if (songSelect != null) + scopedBeatmapSet.BindTo(songSelect.ScopedBeatmapSet); + + configManager.BindWith(OsuSetting.ShowConvertedBeatmaps, showConvertedBeatmaps); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Beatmap.BindValueChanged(_ => updateBeatmap()); + StarDifficulty.BindValueChanged(_ => updateBeatmap()); + showConvertedBeatmaps.BindValueChanged(_ => updateBeatmap()); + scopedBeatmapSet.BindValueChanged(_ => updateBeatmap(), true); + Enabled.BindValueChanged(_ => updateAppearance(), true); + FinishTransforms(true); + } + + private void updateBeatmap() + { + if (Beatmap.Value == null || scopedBeatmapSet.Value != null) + { + this.FadeOut(transition_duration, Easing.OutQuint); + return; + } + + preceding.Clear(); + succeeding.Clear(); + + var otherStarDifficulties = Beatmap.Value.BeatmapSet!.Beatmaps + .Except([Beatmap.Value]) + .Where(b => b.AllowGameplayWithRuleset(ruleset.Value, showConvertedBeatmaps.Value)) + .OrderBy(b => b.StarRating) + .Select(b => b.StarRating) + .ToList(); + this.FadeTo(otherStarDifficulties.Count > 0 ? 1 : 0, transition_duration, Easing.OutQuint); + + if (otherStarDifficulties.Count == 0) + return; + + const int max_difficulties_total = 11; + + int startIndex; + int endIndex; + + if (otherStarDifficulties.Count <= max_difficulties_total) + { + startIndex = 0; + endIndex = otherStarDifficulties.Count - 1; + } + else + { + startIndex = otherStarDifficulties.BinarySearch(StarDifficulty.Value.Stars); + if (startIndex < 0) + startIndex = ~startIndex - 1; + + startIndex = Math.Clamp(startIndex - max_difficulties_total / 2, 0, otherStarDifficulties.Count - 1); + endIndex = Math.Clamp(startIndex + max_difficulties_total, 0, otherStarDifficulties.Count - 1); + } + + for (int i = startIndex; i <= endIndex; i++) + { + double otherStarDifficulty = otherStarDifficulties[i]; + var target = otherStarDifficulty < StarDifficulty.Value.Stars ? preceding : succeeding; + + var circle = new Circle + { + Size = new Vector2(5, 10), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = colours.ForStarDifficulty(otherStarDifficulty) + }; + target.Add(circle); + target.SetLayoutPosition(circle, (float)otherStarDifficulty); + } + + int countNotShown = otherStarDifficulties.Count - (preceding.Count + succeeding.Count); + countText.Alpha = countNotShown > 0 ? 1 : 0; + countText.Text = $@"+{countNotShown}"; + + if (startIndex > 0) + { + for (int i = 0; i < preceding.Count; ++i) + { + var dot = preceding[i]; + dot.Alpha = (1 + 4 * (float)(i + 1) / preceding.Count) / 5; + } + } + + if (endIndex < otherStarDifficulties.Count - 1) + { + for (int i = 0; i < succeeding.Count; ++i) + { + var dot = succeeding[i]; + dot.Alpha = (1 + 4 * (float)(succeeding.Count - i) / succeeding.Count) / 5; + } + } + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (!Enabled.Value) + return false; + + base.OnMouseDown(e); + return true; + } + + protected override bool OnClick(ClickEvent e) + { + if (!Enabled.Value) + return false; + + return base.OnClick(e); + } + + protected override bool OnHover(HoverEvent e) + { + updateAppearance(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateAppearance(); + base.OnHoverLost(e); + } + + private void updateAppearance() + { + bool isInteractable = Enabled.Value && IsHovered; + + HoverColour = isInteractable ? Colour4.White.Opacity(0.1f) : Colour4.Transparent; + preceding.FadeTo(isInteractable ? 1 : 0.5f, transition_duration, Easing.OutQuint); + succeeding.FadeTo(isInteractable ? 1 : 0.5f, transition_duration, Easing.OutQuint); + icon.FadeTo(isInteractable ? 1 : 0, transition_duration, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/Select/PanelBeatmapStandalone.cs similarity index 94% rename from osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs rename to osu.Game/Screens/Select/PanelBeatmapStandalone.cs index 53ade139e224..77cdbc356c0b 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs +++ b/osu.Game/Screens/Select/PanelBeatmapStandalone.cs @@ -18,14 +18,13 @@ using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osuTK; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelBeatmapStandalone : Panel { @@ -46,9 +45,6 @@ public partial class PanelBeatmapStandalone : Panel [Resolved] private BeatmapManager beatmaps { get; set; } = null!; - [Resolved] - private OsuColour colours { get; set; } = null!; - [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } = null!; @@ -65,7 +61,7 @@ public partial class PanelBeatmapStandalone : Panel private ConstrainedIconContainer difficultyIcon = null!; private StarRatingDisplay starRatingDisplay = null!; - private StarCounter starCounter = null!; + private SpreadDisplay spreadDisplay = null!; private PanelLocalRankDisplay localRank = null!; private OsuSpriteText keyCountText = null!; private OsuSpriteText difficultyText = null!; @@ -193,11 +189,10 @@ private void load() Anchor = Anchor.CentreLeft, Scale = new Vector2(0.875f), }, - starCounter = new StarCounter + spreadDisplay = new SpreadDisplay { - Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Scale = new Vector2(0.4f) + Anchor = Anchor.CentreLeft, } }, } @@ -215,7 +210,11 @@ protected override void LoadComplete() ruleset.BindValueChanged(_ => updateKeyCount()); mods.BindValueChanged(_ => updateKeyCount(), true); - Selected.BindValueChanged(s => Expanded.Value = s.NewValue, true); + Selected.BindValueChanged(s => + { + Expanded.Value = s.NewValue; + spreadDisplay.Enabled.Value = s.NewValue; + }, true); } protected override void PrepareForUse() @@ -239,6 +238,7 @@ protected override void PrepareForUse() authorText.Text = BeatmapsetsStrings.ShowDetailsMappedBy(beatmap.Metadata.Author.Username); computeStarRating(); + spreadDisplay.Beatmap.Value = beatmap; updateKeyCount(); } @@ -252,6 +252,7 @@ protected override void FreeAfterUse() updateButton.BeatmapSet = null; localRank.Beatmap = null; starDifficultyBindable = null; + spreadDisplay.Beatmap.Value = null; starDifficultyCancellationSource?.Cancel(); } @@ -268,7 +269,7 @@ private void computeStarRating() starDifficultyBindable.BindValueChanged(starDifficulty => { starRatingDisplay.Current.Value = starDifficulty.NewValue; - starCounter.Current = (float)starDifficulty.NewValue.Stars; + spreadDisplay.StarDifficulty.Value = starDifficulty.NewValue; }, true); } @@ -289,10 +290,10 @@ protected override void Update() var diffColour = starRatingDisplay.DisplayedDifficultyColour; AccentColour = diffColour; - starCounter.Colour = diffColour; + spreadDisplay.Current.Colour = diffColour; backgroundBorder.Colour = diffColour; - difficultyIcon.Colour = starRatingDisplay.DisplayedStars.Value > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5; + difficultyIcon.Colour = starRatingDisplay.DisplayedDifficultyTextColour; } private void updateKeyCount() diff --git a/osu.Game/Screens/SelectV2/PanelGroup.cs b/osu.Game/Screens/Select/PanelGroup.cs similarity index 99% rename from osu.Game/Screens/SelectV2/PanelGroup.cs rename to osu.Game/Screens/Select/PanelGroup.cs index d2ae495610a8..0b3fed927803 100644 --- a/osu.Game/Screens/SelectV2/PanelGroup.cs +++ b/osu.Game/Screens/Select/PanelGroup.cs @@ -22,7 +22,7 @@ using osuTK.Graphics; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelGroup : Panel { diff --git a/osu.Game/Screens/SelectV2/PanelGroupRankDisplay.cs b/osu.Game/Screens/Select/PanelGroupRankDisplay.cs similarity index 99% rename from osu.Game/Screens/SelectV2/PanelGroupRankDisplay.cs rename to osu.Game/Screens/Select/PanelGroupRankDisplay.cs index 6895c30fee33..0697e992f45f 100644 --- a/osu.Game/Screens/SelectV2/PanelGroupRankDisplay.cs +++ b/osu.Game/Screens/Select/PanelGroupRankDisplay.cs @@ -23,7 +23,7 @@ using osuTK.Graphics; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelGroupRankDisplay : Panel { diff --git a/osu.Game/Screens/SelectV2/PanelGroupRankedStatus.cs b/osu.Game/Screens/Select/PanelGroupRankedStatus.cs similarity index 99% rename from osu.Game/Screens/SelectV2/PanelGroupRankedStatus.cs rename to osu.Game/Screens/Select/PanelGroupRankedStatus.cs index ce175efcf662..fdd48a0a1061 100644 --- a/osu.Game/Screens/SelectV2/PanelGroupRankedStatus.cs +++ b/osu.Game/Screens/Select/PanelGroupRankedStatus.cs @@ -22,7 +22,7 @@ using osuTK.Graphics; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelGroupRankedStatus : Panel { diff --git a/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs b/osu.Game/Screens/Select/PanelGroupStarDifficulty.cs similarity index 99% rename from osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs rename to osu.Game/Screens/Select/PanelGroupStarDifficulty.cs index e6b59334cd5b..1abbd44d9f14 100644 --- a/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs +++ b/osu.Game/Screens/Select/PanelGroupStarDifficulty.cs @@ -21,7 +21,7 @@ using osuTK.Graphics; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelGroupStarDifficulty : Panel { diff --git a/osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs b/osu.Game/Screens/Select/PanelLocalRankDisplay.cs similarity index 98% rename from osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs rename to osu.Game/Screens/Select/PanelLocalRankDisplay.cs index c72835144fa9..a0fdb82a4341 100644 --- a/osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs +++ b/osu.Game/Screens/Select/PanelLocalRankDisplay.cs @@ -16,7 +16,7 @@ using osuTK; using Realms; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelLocalRankDisplay : CompositeDrawable { diff --git a/osu.Game/Screens/SelectV2/PanelSetBackground.cs b/osu.Game/Screens/Select/PanelSetBackground.cs similarity index 99% rename from osu.Game/Screens/SelectV2/PanelSetBackground.cs rename to osu.Game/Screens/Select/PanelSetBackground.cs index 7f15a23b9acc..b6c4be7fd68d 100644 --- a/osu.Game/Screens/SelectV2/PanelSetBackground.cs +++ b/osu.Game/Screens/Select/PanelSetBackground.cs @@ -17,7 +17,7 @@ using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelSetBackground : Container { diff --git a/osu.Game/Screens/SelectV2/PanelUpdateBeatmapButton.cs b/osu.Game/Screens/Select/PanelUpdateBeatmapButton.cs similarity index 98% rename from osu.Game/Screens/SelectV2/PanelUpdateBeatmapButton.cs rename to osu.Game/Screens/Select/PanelUpdateBeatmapButton.cs index e7204eefafbe..fc83fa77ef6d 100644 --- a/osu.Game/Screens/SelectV2/PanelUpdateBeatmapButton.cs +++ b/osu.Game/Screens/Select/PanelUpdateBeatmapButton.cs @@ -18,12 +18,11 @@ using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays; -using osu.Game.Screens.Select.Carousel; using osuTK; using osuTK.Graphics; using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class PanelUpdateBeatmapButton : OsuAnimatedButton { diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs deleted file mode 100644 index ae318de754de..000000000000 --- a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -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.Screens.Select.Leaderboards; - -namespace osu.Game.Screens.Select -{ - public partial class PlayBeatmapDetailArea : BeatmapDetailArea - { - public readonly BeatmapLeaderboard Leaderboard; - - public override WorkingBeatmap Beatmap - { - get => base.Beatmap; - set - { - base.Beatmap = value; - - Leaderboard.BeatmapInfo = value is DummyWorkingBeatmap ? null : value?.BeatmapInfo; - } - } - - private Bindable selectedTab; - - private Bindable selectedModsFilter; - - public PlayBeatmapDetailArea() - { - Add(Leaderboard = new BeatmapLeaderboard { RelativeSizeAxes = Axes.Both }); - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - selectedTab = config.GetBindable(OsuSetting.BeatmapDetailTab); - selectedModsFilter = config.GetBindable(OsuSetting.BeatmapDetailModsFilter); - - selectedTab.BindValueChanged(tab => CurrentTab.Value = getTabItemFromTabType(tab.NewValue), true); - CurrentTab.BindValueChanged(tab => selectedTab.Value = getTabTypeFromTabItem(tab.NewValue)); - - selectedModsFilter.BindValueChanged(checkbox => CurrentModsFilter.Value = checkbox.NewValue, true); - CurrentModsFilter.BindValueChanged(checkbox => selectedModsFilter.Value = checkbox.NewValue); - } - - public override void Refresh() - { - base.Refresh(); - - Leaderboard.RefetchScores(); - } - - protected override void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) - { - base.OnTabChanged(tab, selectedMods); - - Leaderboard.FilterMods = selectedMods; - - switch (tab) - { - case BeatmapDetailAreaLeaderboardTabItem leaderboard: - Leaderboard.Scope = leaderboard.Scope; - Leaderboard.Show(); - break; - - default: - Leaderboard.Hide(); - break; - } - } - - protected override BeatmapDetailAreaTabItem[] CreateTabItems() => base.CreateTabItems().Concat(new BeatmapDetailAreaTabItem[] - { - new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local), - new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global), - new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country), - new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend), - new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Team), - }).ToArray(); - - private BeatmapDetailAreaTabItem getTabItemFromTabType(BeatmapDetailTab type) - { - switch (type) - { - case BeatmapDetailTab.Details: - return new BeatmapDetailAreaDetailTabItem(); - - case BeatmapDetailTab.Local: - return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local); - - case BeatmapDetailTab.Global: - return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global); - - case BeatmapDetailTab.Country: - return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country); - - case BeatmapDetailTab.Friends: - return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend); - - case BeatmapDetailTab.Team: - return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Team); - - default: - throw new ArgumentOutOfRangeException(nameof(type)); - } - } - - private BeatmapDetailTab getTabTypeFromTabItem(BeatmapDetailAreaTabItem item) - { - switch (item) - { - case BeatmapDetailAreaDetailTabItem: - return BeatmapDetailTab.Details; - - case BeatmapDetailAreaLeaderboardTabItem leaderboardTab: - switch (leaderboardTab.Scope) - { - case BeatmapLeaderboardScope.Local: - return BeatmapDetailTab.Local; - - case BeatmapLeaderboardScope.Country: - return BeatmapDetailTab.Country; - - case BeatmapLeaderboardScope.Global: - return BeatmapDetailTab.Global; - - case BeatmapLeaderboardScope.Friend: - return BeatmapDetailTab.Friends; - - case BeatmapLeaderboardScope.Team: - return BeatmapDetailTab.Team; - - default: - throw new ArgumentOutOfRangeException(nameof(item)); - } - - default: - throw new ArgumentOutOfRangeException(nameof(item)); - } - } - } -} diff --git a/osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs b/osu.Game/Screens/Select/RealmPopulatingOnlineLookupSource.cs similarity index 92% rename from osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs rename to osu.Game/Screens/Select/RealmPopulatingOnlineLookupSource.cs index 16df414037f4..f94a1822ecc4 100644 --- a/osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs +++ b/osu.Game/Screens/Select/RealmPopulatingOnlineLookupSource.cs @@ -15,7 +15,7 @@ using osu.Game.Online.API.Requests.Responses; using Realms; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { /// /// This component is designed to perform lookups of online data @@ -43,7 +43,7 @@ public partial class RealmPopulatingOnlineLookupSource : Component var request = new GetBeatmapSetRequest(id); var tcs = new TaskCompletionSource(); - token.Register(() => request.Cancel()); + token.Register(request.Cancel); // async request success callback is a bit of a dangerous game, but there's some reasoning for it. // - don't really want to use `IAPIAccess.PerformAsync()` because we still want to respect request queueing & online status checks @@ -70,7 +70,6 @@ public partial class RealmPopulatingOnlineLookupSource : Component private static void updateRealmBeatmapSet(Realm r, APIBeatmapSet onlineBeatmapSet) { - var tagsById = (onlineBeatmapSet.RelatedTags ?? []).ToDictionary(t => t.Id); var onlineBeatmaps = onlineBeatmapSet.Beatmaps.ToDictionary(b => b.OnlineID); var dbBeatmapSets = r.All().Where(b => b.OnlineID == onlineBeatmapSet.OnlineID); @@ -101,11 +100,10 @@ private static void updateRealmBeatmapSet(Realm r, APIBeatmapSet onlineBeatmapSe if (dbBeatmap.MatchesOnlineVersion && dbBeatmap.Status != onlineBeatmap.Status) dbBeatmap.Status = onlineBeatmap.Status; - HashSet userTags = onlineBeatmap.TopTags? - .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) - .Where(t => t.relatedTag != null) - .Select(t => t.relatedTag!.Name) - .ToHashSet() ?? []; + onlineBeatmap.BeatmapSet = onlineBeatmapSet; + HashSet userTags = onlineBeatmap.GetTopUserTags(confirmedOnly: true) + .Select(t => t.Tag.Name) + .ToHashSet(); if (!userTags.SetEquals(dbBeatmap.Metadata.UserTags)) { diff --git a/osu.Game/Screens/Select/SkinDeleteDialog.cs b/osu.Game/Screens/Select/SkinDeleteDialog.cs deleted file mode 100644 index cd14b5b6d274..000000000000 --- a/osu.Game/Screens/Select/SkinDeleteDialog.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Game.Skinning; -using osu.Game.Overlays.Dialog; - -namespace osu.Game.Screens.Select -{ - public partial class SkinDeleteDialog : DeletionDialog - { - private readonly Skin skin; - - public SkinDeleteDialog(Skin skin) - { - this.skin = skin; - BodyText = skin.SkinInfo.Value.Name; - } - - [BackgroundDependencyLoader] - private void load(SkinManager manager) - { - DangerousAction = () => - { - manager.Delete(skin.SkinInfo.Value); - manager.CurrentSkinInfo.SetDefault(); - }; - } - } -} diff --git a/osu.Game/Screens/SelectV2/SoloSongSelect.cs b/osu.Game/Screens/Select/SoloSongSelect.cs similarity index 97% rename from osu.Game/Screens/SelectV2/SoloSongSelect.cs rename to osu.Game/Screens/Select/SoloSongSelect.cs index 697a1f3f555b..270d8f326a40 100644 --- a/osu.Game/Screens/SelectV2/SoloSongSelect.cs +++ b/osu.Game/Screens/Select/SoloSongSelect.cs @@ -19,12 +19,11 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Edit; using osu.Game.Screens.Play; -using osu.Game.Screens.Select; using osu.Game.Users; using osu.Game.Utils; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; -namespace osu.Game.Screens.SelectV2 +namespace osu.Game.Screens.Select { public partial class SoloSongSelect : SongSelect { @@ -135,7 +134,7 @@ Player createPlayer() if (replayGeneratingMod != null) { - player = new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)); + player = new ReplayPlayer(replayGeneratingMod.CreateScoreFromReplayData); } else { diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 606d53d884f2..51e814b1a177 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -5,42 +5,51 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Bindables; -using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Development; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Configuration; -using osu.Game.Graphics; +using osu.Game.Database; +using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Backgrounds; -using osu.Game.Screens.Edit; +using osu.Game.Scoring; +using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; -using osu.Game.Screens.Select.Details; -using osu.Game.Screens.Select.Options; +using osu.Game.Screens.Ranking; using osu.Game.Skinning; using osu.Game.Utils; using osuTK; @@ -49,1036 +58,992 @@ namespace osu.Game.Screens.Select { - public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler + public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler, ISongSelect, IHandlePresentBeatmap, IProvideCursor { - public static readonly float WEDGE_HEIGHT = 200; - - protected const float BACKGROUND_BLUR = 20; - private const float left_area_padding = 20; - - public FilterControl FilterControl { get; private set; } = null!; - /// - /// Whether this song select instance should take control of the global track, - /// applying looping and preview offsets. + /// A debounce that governs how long after a panel is selected before the rest of song select (and the game at large) + /// updates to show that selection. + /// + /// This is intentionally slightly higher than key repeat, but low enough to not impede user experience. /// - protected virtual bool ControlGlobalMusic => true; - - protected virtual bool ShowSongSelectFooter => true; - - public override bool? ApplyModTrackAdjustments => true; + public const int SELECTION_DEBOUNCE = 150; /// - /// Can be null if is false. + /// A general "global" debounce to be applied to anything aggressive difficulty calculation at song select, + /// either after selection or after a panel comes on screen. Value should be low enough that users don't complain, + /// but otherwise as high as possible to reduce overheads. /// - protected BeatmapOptionsOverlay BeatmapOptions { get; private set; } = null!; + public const int DIFFICULTY_CALCULATION_DEBOUNCE = 150; - /// - /// Can be null if is false. - /// - protected Footer? SongSelectFooter { get; private set; } + private const float logo_scale = 0.4f; + private const double fade_duration = 300; - /// - /// Contains any panel which is triggered by a footer button. - /// Helps keep them located beneath the footer itself. - /// - protected Container FooterPanels { get; private set; } = null!; + public const float WEDGE_CONTENT_MARGIN = CORNER_RADIUS_HIDE_OFFSET + OsuGame.SCREEN_EDGE_MARGIN; + public const float CORNER_RADIUS_HIDE_OFFSET = 20f; + public const float ENTER_DURATION = 600; /// - /// The that opens the mod select dialog. + /// Whether this song select instance should take control of the global track, + /// applying looping and preview offsets. /// - protected FooterButton ModsFooterButton { get; private set; } = null!; + protected bool ControlGlobalMusic { get; init; } = true; /// - /// Whether entering editor mode should be allowed. + /// Whether this song select instance should allow scoping down to a specific beatmap set, + /// exposing other difficulties that are otherwise hidden by filter criteria. /// - public virtual bool AllowEditing => true; - - public bool BeatmapSetsLoaded => IsLoaded && Carousel.BeatmapSetsLoaded; + protected bool SupportScoping { init => scopedBeatmapSet.Disabled = !value; } /// - /// Creates any "action" menu items for the provided beatmap (ie. "Select", "Play", "Edit"). - /// These will always be placed at the top of the context menu, with common items added below them. + /// Whether the osu! logo should be shown at the bottom-right of the screen. /// - /// The beatmap to create items for. - /// The menu items. - public virtual MenuItem[] CreateForwardNavigationMenuItemsForBeatmap(Func getBeatmap) => new MenuItem[] - { - new OsuMenuItem(@"Select", MenuItemType.Highlighted, () => FinaliseSelection(getBeatmap())) - }; - - [Resolved] - private OsuGameBase game { get; set; } = null!; + protected bool ShowOsuLogo { get; init; } = true; - [Resolved] - private Bindable> selectedMods { get; set; } = null!; + protected MarginPadding LeftPadding { get; init; } - protected BeatmapCarousel Carousel { get; private set; } = null!; + private ModSelectOverlay modSelectOverlay = null!; + private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!; - private ParallaxContainer wedgeBackground = null!; + // Blue is the most neutral choice, so I'm using that for now. + // Purple makes the most sense to match the "gameplay" flow, but it's a bit too strong for the current design. + // TODO: Colour scheme choice should probably be customisable by the user. + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - protected Container LeftArea { get; private set; } = null!; + private BeatmapCarousel carousel = null!; - private BeatmapInfoWedge beatmapInfoWedge = null!; + protected FilterControl FilterControl { get; private set; } = null!; - [Resolved] - private IDialogOverlay? dialogOverlay { get; set; } + private BeatmapTitleWedge titleWedge = null!; + private BeatmapDetailsArea detailsArea = null!; + private FillFlowContainer wedgesContainer = null!; + private Box rightGradientBackground = null!; + private Container mainContent = null!; + private SkinnableContainer skinnableContent = null!; - [Resolved] - private BeatmapManager beatmaps { get; set; } = null!; + private GridContainer mainGridContainer = null!; - protected ModSelectOverlay ModSelect { get; private set; } = null!; + private NoResultsPlaceholder noResultsPlaceholder = null!; - protected Sample? SampleConfirm { get; private set; } + public override bool? ApplyModTrackAdjustments => true; - private Sample sampleChangeDifficulty = null!; - private Sample sampleChangeBeatmap = null!; + public override bool ShowFooter => true; - private bool pendingFilterApplication; + private Sample? errorSample; - private Container carouselContainer = null!; + [Resolved] + private OsuGameBase? game { get; set; } - protected BeatmapDetailArea BeatmapDetails { get; private set; } = null!; + [Resolved] + private OsuLogo? logo { get; set; } - private FooterButtonOptions beatmapOptionsButton = null!; + [Resolved] + private BeatmapSetOverlay? beatmapOverlay { get; set; } - private readonly Bindable decoupledRuleset = new Bindable(); + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; - private double audioFeedbackLastPlaybackTime; + [Resolved] + private IAPIProvider api { get; set; } = null!; - private IDisposable? modSelectOverlayRegistration; - private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!; + [Resolved] + private ManageCollectionsDialog? collectionsDialog { get; set; } - private AdvancedStats advancedStats = null!; + [Resolved] + private DifficultyRecommender? difficultyRecommender { get; set; } [Resolved] - private MusicController music { get; set; } = null!; + private IDialogOverlay? dialogOverlay { get; set; } [Resolved] - internal IOverlayManager? OverlayManager { get; private set; } + private IOverlayManager? overlayManager { get; set; } - private Bindable configBackgroundBlur = null!; + private InputManager inputManager = null!; - [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, OsuColour colours, ManageCollectionsDialog? manageCollectionsDialog, DifficultyRecommender? recommender, OsuConfigManager config) - { - configBackgroundBlur = config.GetBindable(OsuSetting.SongSelectBackgroundBlur); - configBackgroundBlur.BindValueChanged(e => - { - if (!this.IsCurrentScreen()) - return; + private readonly RealmPopulatingOnlineLookupSource onlineLookupSource = new RealmPopulatingOnlineLookupSource(); - ApplyToBackground(applyBlurToBackground); - }); + private Bindable configBackgroundBlur = null!; + private Bindable showConvertedBeatmaps = null!; + + private IDisposable? modSelectOverlayRegistration; - // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). - transferRulesetValue(); + [BackgroundDependencyLoader] + private void load(AudioManager audio, OsuConfigManager config) + { + errorSample = audio.Samples.Get(@"UI/generic-error"); AddRangeInternal(new Drawable[] { new GlobalScrollAdjustsVolume(), - new VerticalMaskingContainer + onlineLookupSource, + mainContent = new Container { - Children = new Drawable[] + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = ScreenFooter.HEIGHT }, + Child = new OsuContextMenuContainer { - new GlobalScrollAdjustsVolume(), - new GridContainer // used for max width implementation + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer { RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] + Children = new Drawable[] { - new Dimension(), - new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 850), - }, - Content = new[] - { - new Drawable[] + new Box { - wedgeBackground = new ParallaxContainer - { - ParallaxAmount = 0.005f, - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Child = new WedgeBackground - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = -150 }, - }, - }, - carouselContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Top = FilterControl.HEIGHT, - Bottom = Select.Footer.HEIGHT - }, - Child = new LoadingSpinner(true) { State = { Value = Visibility.Visible } } - } + RelativeSizeAxes = Axes.Both, + Width = 0.6f, + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.3f), Color4.Black.Opacity(0f)), }, - } - }, - FilterControl = CreateFilterControl().With(d => - { - d.RelativeSizeAxes = Axes.X; - d.Height = FilterControl.HEIGHT; - }), - new GridContainer // used for max width implementation - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 650), - }, - Content = new[] - { - new Drawable[] + mainGridContainer = new GridContainer // used for max width implementation { - LeftArea = new Container + RelativeSizeAxes = Axes.Both, + Content = new[] { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 5 }, - Children = new Drawable[] + new[] { - new LeftSideInteractionContainer(() => Carousel.ScrollToSelected()) + new Container { RelativeSizeAxes = Axes.Both, - }, - beatmapInfoWedge = new BeatmapInfoWedge - { - Height = WEDGE_HEIGHT, - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding + // Ensure the left components are on top of the carousel both visually (although they should never overlay) + // but more importantly, for input purposes to allow the scroll-to-selection logic to override carousel's + // screen-wide scroll handling. + Depth = float.MinValue, + Shear = OsuGame.SHEAR, + Padding = new MarginPadding { - Right = left_area_padding, - Left = -BeatmapInfoWedge.BORDER_THICKNESS, // Hide the left border + Top = -CORNER_RADIUS_HIDE_OFFSET, + Left = -CORNER_RADIUS_HIDE_OFFSET, }, + Children = new Drawable[] + { + new Container + { + // Pad enough to only reset scroll when well into the left wedge areas. + Padding = new MarginPadding { Right = 40 }, + RelativeSizeAxes = Axes.Both, + Child = new LeftSideInteractionContainer(() => carousel.ScrollToSelection()) + { + RelativeSizeAxes = Axes.Both, + }, + }, + wedgesContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Spacing = new Vector2(0f, 4f), + Direction = FillDirection.Vertical, + Padding = LeftPadding, + Children = new Drawable[] + { + new ShearAligningWrapper(titleWedge = new BeatmapTitleWedge()), + new ShearAligningWrapper(detailsArea = new BeatmapDetailsArea()), + }, + }, + } }, + Empty(), new Container { - RelativeSizeAxes = Axes.X, - Height = 90, - Padding = new MarginPadding(10) - { - Left = left_area_padding, - Right = left_area_padding * 2 + 5, - }, - Y = WEDGE_HEIGHT, + RelativeSizeAxes = Axes.Both, Children = new Drawable[] { + rightGradientBackground = new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.0f), Color4.Black.Opacity(0.5f)), + RelativeSizeAxes = Axes.Both, + }, new Container { RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 10, + Padding = new MarginPadding + { + Top = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5, + Bottom = 5, + }, Children = new Drawable[] { - new Box + carousel = new BeatmapCarousel { + BleedTop = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5, + BleedBottom = ScreenFooter.HEIGHT + 5, RelativeSizeAxes = Axes.Both, - Colour = Colour4.Black.Opacity(0.3f), + RequestPresentBeatmap = b => SelectAndRun(b, OnStart), + RequestSelection = queueBeatmapSelection, + RequestRecommendedSelection = requestRecommendedSelection, + NewItemsPresented = newItemsPresented, }, - advancedStats = new AdvancedStats(2) + noResultsPlaceholder = new NoResultsPlaceholder { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Padding = new MarginPadding(10), - }, + RequestClearFilterText = () => FilterControl.Search(string.Empty) + } } }, + FilterControl = new FilterControl + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet }, + }, } }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Bottom = Select.Footer.HEIGHT, - Top = WEDGE_HEIGHT + 70, - Left = left_area_padding, - Right = left_area_padding * 2, - }, - Child = BeatmapDetails = CreateBeatmapDetailArea().With(d => - { - d.RelativeSizeAxes = Axes.Both; - d.Padding = new MarginPadding { Top = 10, Right = 5 }; - }) - }, - } - }, + }, + } }, } - } + }, } }, - new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.SongSelect)) + skinnableContent = new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.SongSelect)) { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, }, - modSpeedHotkeyHandler = new ModSpeedHotkeyHandler(), + modSpeedHotkeyHandler = new ModSpeedHotkeyHandler() }); - // Important to load this after the filter control is loaded (so we have initial filter criteria prepared). - LoadComponentAsync(Carousel = new BeatmapCarousel(FilterControl.CreateCriteria()) - { - AllowSelection = false, // delay any selection until our bindables are ready to make a good choice. - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Both, - BleedTop = FilterControl.HEIGHT, - BleedBottom = Select.Footer.HEIGHT, - SelectionChanged = updateSelectedBeatmap, - BeatmapSetsChanged = carouselBeatmapsLoaded, - FilterApplied = () => Scheduler.AddOnce(updateVisibleBeatmapCount), - GetRecommendedBeatmap = s => recommender?.GetRecommendedBeatmap(s), - }, c => carouselContainer.Child = c); - - FilterControl.FilterChanged = criteria => - { - // If a filter operation is applied when we're in a state that doesn't allow selection, - // we might end up in an unexpected state. This is because currently carousel panels are in charge - // of updating the global selection (which is very hard to deal with). - // - // For now let's just avoid filtering when selection isn't allowed locally. - // This should be nuked from existence when we get around to fixing the complexity of song select <-> beatmap carousel. - // The debounce part of BeatmapCarousel's filtering should probably also be removed and handled locally. - if (Carousel.AllowSelection) - Carousel.Filter(criteria); - else - pendingFilterApplication = true; - }; - - if (ShowSongSelectFooter) - { - AddRangeInternal(new Drawable[] - { - FooterPanels = new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = Select.Footer.HEIGHT }, - Children = new Drawable[] - { - BeatmapOptions = new BeatmapOptionsOverlay(), - } - }, - SongSelectFooter = new Footer() - }); - } - - // preload the mod select overlay for later use in `LoadComplete()`. - // therein it will be registered at the `OsuGame` level to properly function as a blocking overlay. - LoadComponent(ModSelect = CreateModSelectOverlay()); + LoadComponent(modSelectOverlay = CreateModSelectOverlay()); - if (SongSelectFooter != null) + configBackgroundBlur = config.GetBindable(OsuSetting.SongSelectBackgroundBlur); + configBackgroundBlur.BindValueChanged(e => { - foreach (var (button, overlay) in CreateSongSelectFooterButtons()) - SongSelectFooter.AddButton(button, overlay); + if (!this.IsCurrentScreen()) + return; - BeatmapOptions.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, () => manageCollectionsDialog?.Show()); - BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => DeleteBeatmap(Beatmap.Value.BeatmapSetInfo)); - BeatmapOptions.AddButton(@"Mark", @"as played", FontAwesome.Regular.TimesCircle, colours.Purple, () => beatmaps.MarkPlayed(Beatmap.Value.BeatmapInfo)); - BeatmapOptions.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, () => ClearScores(Beatmap.Value.BeatmapInfo)); - } + updateBackgroundDim(); + }); - sampleChangeDifficulty = audio.Samples.Get(@"SongSelect/select-difficulty"); - sampleChangeBeatmap = audio.Samples.Get(@"SongSelect/select-expand"); - SampleConfirm = audio.Samples.Get(@"SongSelect/confirm-selection"); + showConvertedBeatmaps = config.GetBindable(OsuSetting.ShowConvertedBeatmaps); } - protected virtual FilterControl CreateFilterControl() => new FilterControl(); - - protected override void LoadComplete() + // Colour scheme for mod overlay is left as default (green) to match mods button. + // Not sure about this, but we'll iterate based on feedback. + protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay { - base.LoadComplete(); - - modSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(ModSelect); - } + ShowPresets = true, + }; - protected override bool OnScroll(ScrollEvent e) + private void requestRecommendedSelection(IEnumerable groupedBeatmaps) { - // Match stable behaviour of only alt-scroll adjusting volume. - // Supporting scroll adjust without a modifier key just feels bad, since there are so many scrollable elements on the screen. - if (!e.CurrentState.Keyboard.AltPressed) - return true; - - return base.OnScroll(e); + var recommendedBeatmap = difficultyRecommender?.GetRecommendedBeatmap(groupedBeatmaps.Select(gb => gb.Beatmap)) ?? groupedBeatmaps.First().Beatmap; + queueBeatmapSelection(groupedBeatmaps.First(bug => bug.Beatmap.Equals(recommendedBeatmap))); } /// - /// Creates the buttons to be displayed in the footer. + /// Called when a selection is made to progress away from the song select screen. + /// + /// This is the default action which should be provided to . /// - /// A set of and an optional which the button opens when pressed. - protected virtual IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() => new (FooterButton, OverlayContainer?)[] + protected abstract void OnStart(); + + public override IReadOnlyList CreateFooterButtons() => new ScreenFooterButton[] { - (ModsFooterButton = new FooterButtonMods { Current = Mods }, ModSelect), - (new FooterButtonRandom + new FooterButtonMods(modSelectOverlay) + { + Hotkey = GlobalAction.ToggleModSelection, + Current = Mods, + RequestDeselectAllMods = () => + { + if (modSelectOverlay.State.Value == Visibility.Visible) + modSelectOverlay.DeselectAll(); + else + Mods.Value = Array.Empty(); + } + }, + new FooterButtonRandom + { + NextRandom = () => + { + if (!carousel.NextRandom()) + errorSample?.Play(); + }, + PreviousRandom = () => + { + if (!carousel.PreviousRandom()) + errorSample?.Play(); + } + }, + new FooterButtonOptions { - NextRandom = () => Carousel.SelectNextRandom(), - PreviousRandom = Carousel.SelectPreviousRandom - }, null), - (beatmapOptionsButton = new FooterButtonOptions(), BeatmapOptions) + Hotkey = GlobalAction.ToggleBeatmapOptions, + } }; - protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay + protected override void LoadComplete() { - ShowPresets = true, - }; + base.LoadComplete(); - private DependencyContainer dependencies = null!; + modSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(modSelectOverlay); - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + inputManager = GetContainingInputManager()!; - dependencies.CacheAs(this); - dependencies.CacheAs(decoupledRuleset); - dependencies.CacheAs>(decoupledRuleset); + FilterControl.CriteriaChanged += criteriaChanged; - return dependencies; - } + modSelectOverlay.State.BindValueChanged(v => + { + if (!this.IsCurrentScreen()) + return; - /// - /// Creates the beatmap details to be displayed underneath the wedge. - /// - protected abstract BeatmapDetailArea CreateBeatmapDetailArea(); + if (ShowOsuLogo) + logo?.FadeTo(v.NewValue == Visibility.Visible ? 0f : 1f, 200, Easing.OutQuint); + }); + } - public void Edit(BeatmapInfo? beatmapInfo = null) + protected override void Update() { - if (!AllowEditing) - throw new InvalidOperationException($"Attempted to edit when {nameof(AllowEditing)} is disabled"); + base.Update(); + + detailsArea.Height = wedgesContainer.ChildSize.Y - titleWedge.LayoutSize.Y - 4; - // Forced refetch is important here to guarantee correct invalidation across all difficulties. - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo ?? beatmapInfoNoDebounce, true); + float widescreenBonusWidth = Math.Max(0, DrawWidth / DrawHeight - 2f); - FinaliseSelection(customStartAction: () => this.Push(new EditorLoader())); + mainGridContainer.ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 700 + widescreenBonusWidth * 100), + new Dimension(), + new Dimension(GridSizeMode.Relative, 0.5f, minSize: 500, maxSize: 700 + widescreenBonusWidth * 300), + }; + + if (this.IsCurrentScreen()) + updateDebounce(); } - /// - /// Set the query to the search text box. - /// - /// The string to search. - public void Search(string query) + #region Selection debounce + + private BeatmapInfo? debounceQueuedSelection; + private double debounceElapsedTime; + + private void debounceQueueSelection(BeatmapInfo beatmap) { - FilterControl.CurrentTextSearch.Value = query; + debounceQueuedSelection = beatmap; + debounceElapsedTime = 0; } - /// - /// Call to make a selection and perform the default action for this SongSelect. - /// - /// An optional beatmap to override the current carousel selection. - /// An optional ruleset to override the current carousel selection. - /// An optional custom action to perform instead of . - public void FinaliseSelection(BeatmapInfo? beatmapInfo = null, RulesetInfo? ruleset = null, Action? customStartAction = null) + private void updateDebounce() { - // This is very important as we have not yet bound to screen-level bindables before the carousel load is completed. - if (!Carousel.BeatmapSetsLoaded) - { - Logger.Log($"{nameof(FinaliseSelection)} aborted as carousel beatmaps are not yet loaded"); - return; - } + if (debounceQueuedSelection == null) return; + + double elapsed = Clock.ElapsedFrameTime; - if (ruleset != null) - Ruleset.Value = ruleset; + // When a key is being held, assume the user is traversing the carousel using key repeat. + // We want to change panels less often in this state (basically making debounce longer than initial key repeat, at least). + double debounceInterval = inputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed ? SELECTION_DEBOUNCE * 2 : SELECTION_DEBOUNCE; - transferRulesetValue(); + // avoid debounce running early if there's a single long frame. + if (!DebugUtils.IsNUnitRunning && Clock.FramesPerSecond > 0) + elapsed = Math.Min(1000 / Clock.FramesPerSecond, elapsed); - // while transferRulesetValue will flush, it only does so if the ruleset changes. - // the user could have changed a filter, and we want to ensure we are 100% up-to-date and consistent here. - Carousel.FlushPendingFilterOperations(); + debounceElapsedTime += elapsed; - // avoid attempting to continue before a selection has been obtained. - // this could happen via a user interaction while the carousel is still in a loading state. - if (Carousel.SelectedBeatmapInfo == null) return; + if (debounceElapsedTime >= debounceInterval) + performDebounceSelection(); + } - if (beatmapInfo != null) - Carousel.SelectBeatmap(beatmapInfo); + private void performDebounceSelection() + { + if (debounceQueuedSelection == null) return; - if (selectionChangedDebounce?.Completed == false) + try { - selectionChangedDebounce.RunTask(); - selectionChangedDebounce?.Cancel(); // cancel the already scheduled task. - selectionChangedDebounce = null; - } + if (Beatmap.Value.BeatmapInfo.Equals(debounceQueuedSelection)) + return; - if (customStartAction != null) + Beatmap.Value = beatmaps.GetWorkingBeatmap(debounceQueuedSelection); + } + finally { - customStartAction(); - Carousel.AllowSelection = false; + cancelDebounceSelection(); } - else if (OnStart()) - Carousel.AllowSelection = false; } + private void cancelDebounceSelection() + { + debounceQueuedSelection = null; + debounceElapsedTime = 0; + } + + #endregion + + #region Audio + + [Resolved] + private MusicController music { get; set; } = null!; + + private readonly WeakReference lastTrack = new WeakReference(null); + /// - /// Called when a selection is made. + /// Ensures some music is playing for the current track. + /// Will resume playback from a manual user pause if the track has changed. /// - /// If a resultant action occurred that takes the user away from SongSelect. - protected abstract bool OnStart(); + private void ensurePlayingSelected() + { + if (!ControlGlobalMusic) + return; - private ScheduledDelegate? selectionChangedDebounce; + ITrack track = music.CurrentTrack; - private void updateCarouselSelection(ValueChangedEvent e = default) - { - var beatmap = e.NewValue ?? Beatmap.Value; - if (beatmap is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; + bool isNewTrack = !lastTrack.TryGetTarget(out var last) || last != track; - if (beatmap.BeatmapSetInfo.Protected) + if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) { - Logger.Log($"Denying working beatmap switch to protected beatmap {beatmap}"); - Beatmap.Value = e.OldValue; - return; + Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}"); + + // Only restart playback if a new track. + // This is important so that when exiting gameplay, the track is not restarted back to the preview point. + music.Play(isNewTrack); } - Logger.Log($"Song select working beatmap updated to {beatmap}"); + lastTrack.SetTarget(track); + } + + private bool isHandlingLooping; - if (!Carousel.SelectBeatmap(beatmap.BeatmapInfo, false)) - { - // A selection may not have been possible with filters applied. + private void beginLooping() + { + Debug.Assert(!isHandlingLooping); - // There was possibly a ruleset mismatch. This is a case we can help things along by updating the game-wide ruleset to match. - if (!beatmap.BeatmapInfo.Ruleset.Equals(decoupledRuleset.Value)) - { - Ruleset.Value = beatmap.BeatmapInfo.Ruleset; - transferRulesetValue(); - } + isHandlingLooping = true; - // Even if a ruleset mismatch was not the cause (ie. a text filter is applied), - // we still want to temporarily show the new beatmap, bypassing filters. - // This will be undone the next time the user changes the filter. - var criteria = FilterControl.CreateCriteria(); - criteria.SelectedBeatmapSet = beatmap.BeatmapInfo.BeatmapSet; - Carousel.Filter(criteria); + ensureTrackLooping(Beatmap.Value, TrackChangeDirection.None); - Carousel.SelectBeatmap(beatmap.BeatmapInfo); - } + music.TrackChanged += ensureTrackLooping; } - // We need to keep track of the last selected beatmap ignoring debounce to play the correct selection sounds. - private BeatmapInfo? beatmapInfoPrevious; - private BeatmapInfo? beatmapInfoNoDebounce; - private RulesetInfo? rulesetNoDebounce; - - private void updateSelectedBeatmap(BeatmapInfo? beatmapInfo) + private void endLooping() { - if (beatmapInfo == null && beatmapInfoNoDebounce == null) + // may be called multiple times during screen exit process. + if (!isHandlingLooping) return; - if (beatmapInfo?.Equals(beatmapInfoNoDebounce) == true) - return; + music.CurrentTrack.Looping = isHandlingLooping = false; - beatmapInfoNoDebounce = beatmapInfo; - performUpdateSelected(); + music.TrackChanged -= ensureTrackLooping; } - private void updateSelectedRuleset(RulesetInfo? ruleset) + private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection) + => beatmap.PrepareTrackForPreview(true); + + #endregion + + #region Selection handling + + /// + /// Finalises selection on the given and runs the provided action if possible. + /// + /// The beatmap which should be selected. If not provided, the current globally selected beatmap will be used. + /// The action to perform if conditions are met to be able to proceed. May not be invoked if in an invalid state. + protected void SelectAndRun(BeatmapInfo beatmap, Action startAction) { - if (ruleset == null && rulesetNoDebounce == null) + if (!this.IsCurrentScreen()) + return; + + if (!checkBeatmapValidForSelection(beatmap)) return; - if (ruleset?.Equals(rulesetNoDebounce) == true) + // To ensure sanity, cancel any pending selection as we are about to force a selection. + // Carousel selection will update to the forced selection via a call of `ensureGlobalBeatmapValid` below, or when song select becomes current again. + cancelDebounceSelection(); + + // Forced refetch is important here to guarantee correct invalidation across all difficulties (editor specific). + Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap, true); + + if (Beatmap.IsDefault) return; - rulesetNoDebounce = ruleset; - performUpdateSelected(); + startAction(); } /// - /// Selection has been changed as the result of a user interaction. + /// Prepares the proposed beatmap for global selection based on a carousel user-performed action. /// - private void performUpdateSelected() + /// + /// Calling this method will: + /// - Immediately update the selection the carousel. + /// - After , update the global beatmap. This in turn causes song select visuals (title, details, leaderboard) to update. + /// This debounce is intended to avoid high overheads from churning lookups while a user is changing selection via rapid keyboard operations. + /// + /// The beatmap to be selected. + private void queueBeatmapSelection(GroupedBeatmap groupedBeatmap) { - var beatmap = beatmapInfoNoDebounce; - RulesetInfo? ruleset = rulesetNoDebounce; + if (!this.IsCurrentScreen()) + return; - selectionChangedDebounce?.Cancel(); + carousel.CurrentGroupedBeatmap = groupedBeatmap; - if (beatmapInfoNoDebounce == null) - run(); - else + // Debounce consideration is to avoid beatmap churn on key repeat selection. + debounceQueueSelection(groupedBeatmap.Beatmap); + } + + private bool ensureGlobalBeatmapValid() + { + if (!this.IsCurrentScreen()) + return false; + + performDebounceSelection(); + + // While filtering, let's not ever attempt to change selection. + // This will be resolved after the filter completes, see `newItemsPresented`. + if (IsFiltering) + return false; + + // Refetch to be confident that the current selection is still valid. It may have been deleted or hidden. + var currentBeatmap = beatmaps.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo, true); + bool validSelection = checkBeatmapValidForSelection(currentBeatmap.BeatmapInfo); + + if (validSelection) { - // Intentionally slightly higher than repeat_tick_rate to avoid loading songs when holding left / right arrows. - // See https://github.com/ppy/osu-framework/blob/master/osu.Framework/Input/InputManager.cs#L44 - selectionChangedDebounce = Scheduler.AddDelayed(run, 80); + carousel.CurrentBeatmap = currentBeatmap.BeatmapInfo; + return true; } - if (beatmap?.Equals(beatmapInfoPrevious) != true) + // If there was no beatmap selected, pick a random one. + if (Beatmap.IsDefault) { - if (beatmap != null && beatmapInfoPrevious != null && Time.Current - audioFeedbackLastPlaybackTime >= 50) - { - if (beatmap.BeatmapSet?.ID == beatmapInfoPrevious.BeatmapSet?.ID) - sampleChangeDifficulty.Play(); - else - sampleChangeBeatmap.Play(); - - audioFeedbackLastPlaybackTime = Time.Current; - } - - beatmapInfoPrevious = beatmap; + validSelection = carousel.NextRandom(); + performDebounceSelection(); + return validSelection; } - void run() + // If a previous non-default selection became non-valid, it was likely hidden or deleted. + if (!validSelection) { - // clear pending task immediately to track any potential nested debounce operation. - selectionChangedDebounce = null; + // In the case a difficulty was hidden or removed, prefer selecting another difficulty from the same set. + var activeSet = currentBeatmap.BeatmapSetInfo; - Logger.Log($"Song select updating selection with beatmap: {beatmap} {beatmap?.ID.ToString() ?? "null"} ruleset:{ruleset?.ShortName ?? "null"}"); + var validBeatmaps = activeSet.Beatmaps.Where(checkBeatmapValidForSelection).ToArray(); - if (transferRulesetValue()) + if (validBeatmaps.Any()) { - // transferRulesetValue() may trigger a re-filter. If the current selection does not match the new ruleset, we want to switch away from it. - // The default logic on WorkingBeatmap change is to switch to a matching ruleset (see workingBeatmapChanged()), but we don't want that here. - // We perform an early selection attempt and clear out the beatmap selection to avoid a second ruleset change (revert). - if (beatmap != null && !Carousel.SelectBeatmap(beatmap, false)) - beatmap = null; + var beatmap = difficultyRecommender?.GetRecommendedBeatmap(validBeatmaps) ?? validBeatmaps.First(); + carousel.CurrentBeatmap = beatmap; + debounceQueueSelection(beatmap); + return true; } - - if (selectionChangedDebounce != null) - { - // a new nested operation was started; switch to it for further selection. - // this avoids having two separate debounces trigger from the same source. - selectionChangedDebounce.RunTask(); - return; - } - - // We may be arriving here due to another component changing the bindable Beatmap. - // In these cases, the other component has already loaded the beatmap, so we don't need to do so again. - if (!EqualityComparer.Default.Equals(beatmap, Beatmap.Value.BeatmapInfo)) - { - Logger.Log($"Song select changing beatmap from \"{Beatmap.Value.BeatmapInfo}\" to \"{beatmap?.ToString() ?? "null"}\""); - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap); - } - - if (this.IsCurrentScreen()) - ensurePlayingSelected(); - - updateComponentFromBeatmap(Beatmap.Value); } - } - public override void OnEntering(ScreenTransitionEvent e) - { - base.OnEntering(e); - - this.FadeInFromZero(250); - FilterControl.Activate(); - - ModSelect.SelectedMods.BindTo(selectedMods); + // If all else fails, use the default beatmap. + Beatmap.SetDefault(); + performDebounceSelection(); - beginLooping(); + return validSelection; } - private const double logo_transition = 250; - - protected override void LogoArriving(OsuLogo logo, bool resuming) + private bool checkBeatmapValidForSelection(BeatmapInfo beatmap) { - base.LogoArriving(logo, resuming); - - logo.RelativePositionAxes = Axes.None; - logo.ChangeAnchor(Anchor.BottomRight); - - Vector2 position = new Vector2(-76, -36); + if (!beatmap.AllowGameplayWithRuleset(Ruleset.Value, showConvertedBeatmaps.Value)) + return false; - if (logo.Alpha > 0.8f) - { - logo.MoveTo(position, 500, Easing.OutQuint); - } - else - { - logo.Hide(); - logo.ScaleTo(0.2f); - logo.MoveTo(position); - } + if (beatmap.Hidden) + return false; - logo.FadeIn(logo_transition, Easing.OutQuint); - logo.ScaleTo(0.4f, logo_transition, Easing.OutQuint); + if (beatmap.BeatmapSet == null) + return false; - logo.Action = () => - { - if (this.IsCurrentScreen()) - FinaliseSelection(); + if (beatmap.BeatmapSet.Protected || beatmap.BeatmapSet.DeletePending) return false; - }; + + return true; } - protected override void LogoExiting(OsuLogo logo) + #endregion + + #region Transitions + + public override void OnEntering(ScreenTransitionEvent e) { - base.LogoExiting(logo); - logo.ScaleTo(0.2f, logo_transition / 2, Easing.Out); - logo.FadeOut(logo_transition / 2, Easing.Out); + base.OnEntering(e); + + this.FadeIn(); + onArrivingAtScreen(); } public override void OnResuming(ScreenTransitionEvent e) { base.OnResuming(e); - // required due to https://github.com/ppy/osu-framework/issues/3218 - ModSelect.SelectedMods.Disabled = false; - ModSelect.SelectedMods.BindTo(selectedMods); - - Carousel.AllowSelection = true; + this.FadeIn(fade_duration, Easing.OutQuint); + onArrivingAtScreen(); - BeatmapDetails.Refresh(); + ensureGlobalBeatmapValid(); - beginLooping(); + detailsArea.Refresh(); - if (!Beatmap.Value.BeatmapSetInfo.DeletePending) + if (ControlGlobalMusic) { - updateCarouselSelection(); - - updateComponentFromBeatmap(Beatmap.Value); - - if (ControlGlobalMusic) - { - // restart playback on returning to song select, regardless. - // not sure this should be a permanent thing (we may want to leave a user pause paused even on returning) - music.ResetTrackAdjustments(); - music.Play(requestedByUser: true); - } + // restart playback on returning to song select, regardless. + // not sure this should be a permanent thing (we may want to leave a user pause paused even on returning) + music.ResetTrackAdjustments(); + music.Play(requestedByUser: true); } + } - LeftArea.MoveToX(0, 400, Easing.OutQuint); - LeftArea.FadeIn(100, Easing.OutQuint); + public override void OnSuspending(ScreenTransitionEvent e) + { + carousel.VisuallyFocusSelected = true; - FilterControl.MoveToY(0, 400, Easing.OutQuint); - FilterControl.FadeIn(100, Easing.OutQuint); + this.FadeOut(fade_duration, Easing.OutQuint); + onLeavingScreen(); - this.FadeIn(250, Easing.OutQuint); + base.OnSuspending(e); + } - wedgeBackground.ScaleTo(1, 500, Easing.OutQuint); + public override bool OnExiting(ScreenExitEvent e) + { + this.FadeOut(fade_duration, Easing.OutQuint); + onLeavingScreen(); - FilterControl.Activate(); + return base.OnExiting(e); } - protected override void Update() + private void onArrivingAtScreen() { - base.Update(); + modSelectOverlay.Beatmap.BindTo(Beatmap); + // required due to https://github.com/ppy/osu-framework/issues/3218 + modSelectOverlay.SelectedMods.Disabled = false; + modSelectOverlay.SelectedMods.BindTo(Mods); + + carousel.VisuallyFocusSelected = false; - if (Carousel.AllowSelection && pendingFilterApplication) + if (ControlGlobalMusic) { - Carousel.Filter(FilterControl.CreateCriteria()); - pendingFilterApplication = false; + // Avoid abruptly starting playback at preview point. + // Importantly, this should be done before looping is setup to ensure we get the correct imminent `IsPlaying` state. + if (!music.IsPlaying) + { + music.DuckMomentarily(0, new DuckParameters + { + DuckDuration = 0, + DuckVolumeTo = 0, + RestoreDuration = 800, + RestoreEasing = Easing.OutQuint + }); + } + + beginLooping(); } + + Beatmap.BindValueChanged(updateVariousState, true); } - public override void OnSuspending(ScreenTransitionEvent e) + private void updateVariousState(ValueChangedEvent e) { - // Handle the case where FinaliseSelection is never called (ie. when a screen is pushed externally). - // Without this, it's possible for a transfer to happen while we are not the current screen. - transferRulesetValue(); + if (!this.IsCurrentScreen()) + return; - ModSelect.SelectedMods.UnbindFrom(selectedMods); + ensureGlobalBeatmapValid(); - playExitingTransition(); - base.OnSuspending(e); + ensurePlayingSelected(); + updateBackgroundDim(); + updateWedgeVisibility(); + fetchOnlineInfo(force: ReferenceEquals(e.OldValue, e.NewValue)); } - public override bool OnExiting(ScreenExitEvent e) + private void onLeavingScreen() { - if (base.OnExiting(e)) - return true; + restoreBackground(); - playExitingTransition(); - return false; - } + Beatmap.ValueChanged -= updateVariousState; - private void playExitingTransition() - { - ModSelect.Hide(); + modSelectOverlay.SelectedMods.UnbindFrom(Mods); + modSelectOverlay.Beatmap.UnbindFrom(Beatmap); - BeatmapOptions.Hide(); - - Carousel.AllowSelection = false; + updateWedgeVisibility(); endLooping(); + } - FilterControl.MoveToY(-120, 500, Easing.OutQuint); - FilterControl.FadeOut(200, Easing.OutQuint); + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); - LeftArea.MoveToX(-150, 1800, Easing.OutQuint); - LeftArea.FadeOut(200, Easing.OutQuint); + if (!ShowOsuLogo) + return; - wedgeBackground.ScaleTo(2.4f, 400, Easing.OutQuint); + if (logo.Alpha > 0.8f && resuming) + Footer?.StartTrackingLogo(logo, 400, Easing.OutQuint); + else + { + logo.Hide(); + logo.ScaleTo(0.2f); + Footer?.StartTrackingLogo(logo); + } - this.FadeOut(400, Easing.OutQuint); + logo.FadeIn(240, Easing.OutQuint); + logo.ScaleTo(logo_scale, 240, Easing.OutQuint); - FilterControl.Deactivate(); + logo.Action = () => + { + ensureGlobalBeatmapValid(); + SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart); + return false; + }; } - private bool isHandlingLooping; - - private void beginLooping() + protected override void LogoSuspending(OsuLogo logo) { - if (!ControlGlobalMusic) - return; - - Debug.Assert(!isHandlingLooping); - - isHandlingLooping = true; + base.LogoSuspending(logo); - ensureTrackLooping(Beatmap.Value, TrackChangeDirection.None); + if (!ShowOsuLogo) + return; - music.TrackChanged += ensureTrackLooping; + Footer?.StopTrackingLogo(); } - private void endLooping() + protected override void LogoExiting(OsuLogo logo) { - // may be called multiple times during screen exit process. - if (!isHandlingLooping) + base.LogoExiting(logo); + + if (!ShowOsuLogo) return; - music.CurrentTrack.Looping = isHandlingLooping = false; + Footer?.StopTrackingLogo(); - music.TrackChanged -= ensureTrackLooping; + logo.ScaleTo(0.2f, 120, Easing.Out); + logo.FadeOut(120, Easing.Out); } - private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection) - => beatmap.PrepareTrackForPreview(true); - - public override bool OnBackButton() + private void updateWedgeVisibility() { - if (ModSelect.State.Value == Visibility.Visible) + // Ensure we don't show an invalid selection before the carousel has finished initially filtering. + // This avoids a flicker of a placeholder or invalid beatmap before a proper selection. + // + // After the carousel finishes filtering, it will attempt a selection then call this method again. + if (!CarouselItemsPresented && !checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo)) + return; + + if (carousel.VisuallyFocusSelected) { - ModSelect.Hide(); - return true; + titleWedge.Hide(); + detailsArea.Hide(); + FilterControl.Hide(); + } + else + { + titleWedge.Show(); + detailsArea.Show(); + FilterControl.Show(); } - - return false; } - protected override void Dispose(bool isDisposing) + private void updateBackgroundDim() => ApplyToBackground(backgroundModeBeatmap => { - base.Dispose(isDisposing); + backgroundModeBeatmap.Beatmap = Beatmap.Value; + backgroundModeBeatmap.IgnoreUserSettings.Value = true; - decoupledRuleset.UnbindAll(); + backgroundModeBeatmap.DimWhenUserSettingsIgnored.Value = 0.1f; - if (music.IsNotNull()) - music.TrackChanged -= ensureTrackLooping; + // Required to undo results screen dimming the background. + // Probably needs more thought because this needs to be in every `ApplyToBackground` currently to restore sane defaults. + backgroundModeBeatmap.FadeColour(Color4.White, 250); - modSelectOverlayRegistration?.Dispose(); - } + bool backgroundRevealActive = revealBackgroundDelegate?.State == ScheduledDelegate.RunState.Running || revealBackgroundDelegate?.State == ScheduledDelegate.RunState.Complete; + backgroundModeBeatmap.BlurAmount.Value = configBackgroundBlur.Value && !backgroundRevealActive ? 20 : 0f; + }); + + #endregion + + #region Filtering /// - /// Allow components in SongSelect to update their loaded beatmap details. - /// This is a debounced call (unlike directly binding to WorkingBeatmap.ValueChanged). + /// Whether the carousel has finished initial presentation of beatmap panels. /// - /// The working beatmap. - private void updateComponentFromBeatmap(WorkingBeatmap beatmap) - { - // If not the current screen, this will be applied in OnResuming. - if (this.IsCurrentScreen()) - { - ApplyToBackground(backgroundModeBeatmap => - { - backgroundModeBeatmap.Beatmap = beatmap; - backgroundModeBeatmap.IgnoreUserSettings.Value = true; - backgroundModeBeatmap.FadeColour(Color4.White, 250); + public bool CarouselItemsPresented { get; private set; } - applyBlurToBackground(backgroundModeBeatmap); - }); - } + /// + /// Whether the carousel is or will be undergoing a filter operation. + /// + public bool IsFiltering => carousel.IsFiltering || filterDebounce?.State == ScheduledDelegate.RunState.Waiting; - beatmapInfoWedge.Beatmap = beatmap; + private const double filter_delay = 250; - BeatmapDetails.Beatmap = beatmap; + private ScheduledDelegate? filterDebounce; - ModSelect.Beatmap.Value = beatmap; + private void criteriaChanged(FilterCriteria criteria) + { + filterDebounce?.Cancel(); - advancedStats.BeatmapInfo = beatmap.BeatmapInfo; - advancedStats.Mods.Value = selectedMods.Value; - advancedStats.Ruleset.Value = Ruleset.Value; + // The first filter needs to be applied immediately as this triggers the initial carousel load. + bool isFirstFilter = filterDebounce == null; - bool beatmapSelected = beatmap is not DummyWorkingBeatmap; + // Criteria change may have included a ruleset change which made the current selection invalid. + bool isSelectionValid = checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo); - if (beatmapSelected) - beatmapOptionsButton.Enabled.Value = true; - else - { - beatmapOptionsButton.Enabled.Value = false; - BeatmapOptions.Hide(); - } + filterDebounce = Scheduler.AddDelayed(() => carousel.Filter(criteria, !isSelectionValid), isFirstFilter || !isSelectionValid ? 0 : filter_delay); } - private void applyBlurToBackground(BackgroundScreenBeatmap backgroundModeBeatmap) + private void newItemsPresented(IEnumerable carouselItems) { - backgroundModeBeatmap.BlurAmount.Value = configBackgroundBlur.Value ? BACKGROUND_BLUR : 0f; - backgroundModeBeatmap.DimWhenUserSettingsIgnored.Value = configBackgroundBlur.Value ? 0 : 0.4f; + if (carousel.Criteria == null) + return; - wedgeBackground.FadeTo(configBackgroundBlur.Value ? 0.5f : 0.2f, UserDimContainer.BACKGROUND_FADE_DURATION, Easing.OutQuint); - } + CarouselItemsPresented = true; - private readonly WeakReference lastTrack = new WeakReference(null); + int count = carousel.MatchedBeatmapsCount; - /// - /// Ensures some music is playing for the current track. - /// Will resume playback from a manual user pause if the track has changed. - /// - private void ensurePlayingSelected() - { - if (!ControlGlobalMusic) - return; - - ITrack track = music.CurrentTrack; + updateNoResultsPlaceholder(); - bool isNewTrack = !lastTrack.TryGetTarget(out var last) || last != track; + // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 + // but also in this case we want support for formatting a number within a string). + FilterControl.StatusText = count != 1 ? $"{count:#,0} matches" : $"{count:#,0} match"; - if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) - { - Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}"); - music.Play(true); - } + // If there's already a selection update in progress, let's not interrupt it. + // Interrupting could cause the debounce interval to be reduced. + // + // `ensureGlobalBeatmapValid` is run post-selection which will resolve any pending incompatibilities (see `Beatmap` bindable callback). + if (debounceQueuedSelection == null) + ensureGlobalBeatmapValid(); - lastTrack.SetTarget(track); + updateWedgeVisibility(); } - private void carouselBeatmapsLoaded() + private void updateNoResultsPlaceholder() { - bindBindables(); - Scheduler.AddOnce(updateVisibleBeatmapCount); - - Carousel.AllowSelection = true; + int count = carousel.MatchedBeatmapsCount; - // If a selection was already obtained, do not attempt to update the selected beatmap. - if (Carousel.SelectedBeatmapSet != null) - return; - - // Attempt to select the current beatmap on the carousel, if it is valid to be selected. - if (!Beatmap.IsDefault && Beatmap.Value.BeatmapSetInfo?.DeletePending == false && Beatmap.Value.BeatmapSetInfo?.Protected == false) + if (count == 0) { - if (Carousel.SelectBeatmap(Beatmap.Value.BeatmapInfo, false)) - return; + if (noResultsPlaceholder.State.Value == Visibility.Hidden) + { + // Duck audio temporarily when the no results placeholder becomes visible. + // + // Temporary ducking makes it easier to avoid scenarios where the ducking interacts badly + // with other global UI components (like overlays). + music.DuckMomentarily(400, new DuckParameters + { + DuckVolumeTo = 1, + DuckCutoffTo = 500, + DuckDuration = 250, + RestoreDuration = 2000, + }); + } - // prefer not changing ruleset at this point, so look for another difficulty in the currently playing beatmap - var found = Beatmap.Value.BeatmapSetInfo.Beatmaps.FirstOrDefault(b => b.Ruleset.Equals(decoupledRuleset.Value)); + noResultsPlaceholder.Show(); + noResultsPlaceholder.Filter = carousel.Criteria!; - if (found != null && Carousel.SelectBeatmap(found, false)) - return; + rightGradientBackground.ResizeWidthTo(3, 1000, Easing.OutPow10); } - - // If the current active beatmap could not be selected, select a new random beatmap. - if (!Carousel.SelectNextRandom()) + else { - // in the case random selection failed, we want to trigger selectionChanged - // to show the dummy beatmap (we have nothing else to display). - performUpdateSelected(); + noResultsPlaceholder.Hide(); + + rightGradientBackground.ResizeWidthTo(1, 400, Easing.OutPow10); } } - private void updateVisibleBeatmapCount() - { - // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 - // but also in this case we want support for formatting a number within a string). - int carouselCountDisplayed = Carousel.CountDisplayed; - FilterControl.InformationalText = carouselCountDisplayed != 1 ? $"{carouselCountDisplayed:#,0} matches" : $"{carouselCountDisplayed:#,0} match"; - } + #endregion - private bool boundLocalBindables; + #region Background reveal - private void bindBindables() - { - if (boundLocalBindables) - return; + private ScheduledDelegate? revealBackgroundDelegate; - // manual binding to parent ruleset to allow for delayed load in the incoming direction. - transferRulesetValue(); + public CursorContainer? Cursor => null; + bool IProvideCursor.ProvidingUserCursor => revealBackgroundDelegate?.Completed == true; - Ruleset.ValueChanged += r => updateSelectedRuleset(r.NewValue); + protected override bool OnHover(HoverEvent e) => true; - decoupledRuleset.ValueChanged += r => - { - bool wasDisabled = Ruleset.Disabled; - - // a sub-screen may have taken a lease on this decoupled ruleset bindable, - // which would indirectly propagate to the game-global bindable via the `DisabledChanged` callback below. - // to make sure changes sync without crashes, lift the disable for a short while to sync, and then restore the old value. - Ruleset.Disabled = false; - Ruleset.Value = r.NewValue; - Ruleset.Disabled = wasDisabled; - }; - decoupledRuleset.DisabledChanged += r => Ruleset.Disabled = r; + protected override bool OnMouseDown(MouseDownEvent e) + { + var containingInputManager = GetContainingInputManager(); - Beatmap.BindValueChanged(updateCarouselSelection); + // I don't know why this works, but it does. + // If the carousel panels are hovered, hovered no longer contains the screen. + // Maybe there's a better way of doing this, but I couldn't immediately find a good setup. + bool mouseDownPriority = containingInputManager!.HoveredDrawables.Contains(this); - selectedMods.BindValueChanged(_ => + // Touch input synthesises right clicks, which allow absolute scroll of the carousel. + // For simplicity, disable this functionality on mobile. + bool isTouchInput = e.CurrentState.Mouse.LastSource is ISourcedFromTouch; + + if (!carousel.AbsoluteScrolling && !isTouchInput && mouseDownPriority && revealBackgroundDelegate == null) { - if (decoupledRuleset.Value.Equals(rulesetNoDebounce)) - advancedStats.Mods.Value = selectedMods.Value; - }, true); + revealBackgroundDelegate = Scheduler.AddDelayed(() => + { + if (containingInputManager.DraggedDrawable != null) + { + revealBackgroundDelegate = null; + return; + } - boundLocalBindables = true; - } + mainContent.ResizeWidthTo(1.2f, 600, Easing.OutQuint); + mainContent.ScaleTo(1.2f, 600, Easing.OutQuint); + mainContent.FadeOut(200, Easing.OutQuint); - /// - /// Transfer the game-wide ruleset to the local decoupled ruleset. - /// Will immediately run filter operations if required. - /// - /// Whether a transfer occurred. - private bool transferRulesetValue() - { - if (decoupledRuleset.Value?.Equals(Ruleset.Value) == true) - return false; + skinnableContent.ResizeWidthTo(1.2f, 600, Easing.OutQuint); + skinnableContent.ScaleTo(1.2f, 600, Easing.OutQuint); + skinnableContent.FadeOut(200, Easing.OutQuint); - Logger.Log($"decoupled ruleset transferred (\"{decoupledRuleset.Value}\" -> \"{Ruleset.Value}\")"); - rulesetNoDebounce = decoupledRuleset.Value = Ruleset.Value; + updateBackgroundDim(); - // if we have a pending filter operation, we want to run it now. - // it could change selection (ie. if the ruleset has been changed). - if (IsLoaded) - Carousel.FlushPendingFilterOperations(); + Footer?.Hide(); + }, 200); + } - return true; + return base.OnMouseDown(e); } - /// - /// Request to delete a specific beatmap. - /// - public void DeleteBeatmap(BeatmapSetInfo? beatmap) + protected override void OnMouseUp(MouseUpEvent e) { - if (beatmap == null) return; - - dialogOverlay?.Push(new BeatmapDeleteDialog(beatmap)); + restoreBackground(); + base.OnMouseUp(e); } - /// - /// Request to clear the scores of a specific beatmap. - /// - public void ClearScores(BeatmapInfo? beatmapInfo) + private void restoreBackground() { - if (beatmapInfo == null) return; + if (revealBackgroundDelegate == null) + return; + + if (revealBackgroundDelegate.State == ScheduledDelegate.RunState.Complete) + { + mainContent.ResizeWidthTo(1f, 500, Easing.OutQuint); + mainContent.ScaleTo(1, 500, Easing.OutQuint); + mainContent.FadeIn(500, Easing.OutQuint); - dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmapInfo, () => - // schedule done here rather than inside the dialog as the dialog may fade out and never callback. - Schedule(() => BeatmapDetails.Refresh()))); + skinnableContent.ResizeWidthTo(1f, 500, Easing.OutQuint); + skinnableContent.ScaleTo(1, 500, Easing.OutQuint); + skinnableContent.FadeIn(500, Easing.OutQuint); + + Footer?.Show(); + } + + revealBackgroundDelegate.Cancel(); + revealBackgroundDelegate = null; + + updateBackgroundDim(); } + #endregion + + #region Input + public virtual bool OnPressed(KeyBindingPressEvent e) { if (!this.IsCurrentScreen()) return false; - switch (e.Action) - { - case GlobalAction.IncreaseModSpeed: - return modSpeedHotkeyHandler.ChangeSpeed(0.05, ModUtils.FlattenMods(game.AvailableMods.Value.SelectMany(kv => kv.Value))); - - case GlobalAction.DecreaseModSpeed: - return modSpeedHotkeyHandler.ChangeSpeed(-0.05, ModUtils.FlattenMods(game.AvailableMods.Value.SelectMany(kv => kv.Value))); - } - - if (e.Repeat) + if (game == null) return false; + var flattenedMods = ModUtils.FlattenMods(game.AvailableMods.Value.SelectMany(kv => kv.Value)); + switch (e.Action) { case GlobalAction.Select: - FinaliseSelection(); + // in most circumstances this is handled already by the carousel itself, but there are cases where it will not be. + // one of which is filtering out all visible beatmaps and attempting to start gameplay. + // in that case, users still expect a `Select` press to advance to gameplay anyway, using the ambient selected beatmap if there is one, + // which matches the behaviour resulting from clicking the osu! cookie in that scenario. + ensureGlobalBeatmapValid(); + SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart); return true; + + case GlobalAction.IncreaseModSpeed: + return modSpeedHotkeyHandler.ChangeSpeed(0.05, flattenedMods); + + case GlobalAction.DecreaseModSpeed: + return modSpeedHotkeyHandler.ChangeSpeed(-0.05, flattenedMods); } return false; @@ -1098,7 +1063,7 @@ protected override bool OnKeyDown(KeyDownEvent e) if (e.ShiftPressed) { if (!Beatmap.IsDefault) - DeleteBeatmap(Beatmap.Value.BeatmapSetInfo); + Delete(Beatmap.Value.BeatmapSetInfo); return true; } @@ -1108,77 +1073,215 @@ protected override bool OnKeyDown(KeyDownEvent e) return base.OnKeyDown(e); } - private partial class VerticalMaskingContainer : Container + #endregion + + #region Online lookups + + public enum BeatmapSetLookupStatus { - private const float panel_overflow = 1.2f; + InProgress, + Completed, + } - protected override Container Content { get; } + public class BeatmapSetLookupResult + { + public BeatmapSetLookupStatus Status { get; } + public APIBeatmapSet? Result { get; } - public VerticalMaskingContainer() + private BeatmapSetLookupResult(BeatmapSetLookupStatus status, APIBeatmapSet? result) { - RelativeSizeAxes = Axes.Both; - Masking = true; - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - Width = panel_overflow; // avoid horizontal masking so the panels don't clip when screen stack is pushed. - InternalChild = Content = new OsuContextMenuContainer - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 1 / panel_overflow, - }; + Status = status; + Result = result; } + + public static BeatmapSetLookupResult InProgress() => new BeatmapSetLookupResult(BeatmapSetLookupStatus.InProgress, null); + public static BeatmapSetLookupResult Completed(APIBeatmapSet? beatmapSet) => new BeatmapSetLookupResult(BeatmapSetLookupStatus.Completed, beatmapSet); } /// - /// Handles mouse interactions required when moving away from the carousel. + /// Result of the latest online beatmap set lookup. + /// Note that this being or is different from + /// being a with a of null. + /// The former indicates a lookup never occurring or being in progress, while the latter indicates a completed lookup with no result. /// - internal partial class LeftSideInteractionContainer : Container + [Cached(typeof(IBindable))] + private readonly Bindable lastLookupResult = new Bindable(); + + private CancellationTokenSource? onlineLookupCancellation; + private Task? currentOnlineLookup; + + private void fetchOnlineInfo(bool force = false) { - private readonly Action? resetCarouselPosition; + var beatmapSetInfo = Beatmap.Value.BeatmapSetInfo; + + if (lastLookupResult.Value?.Result?.OnlineID == beatmapSetInfo.OnlineID && !force) + return; + + onlineLookupCancellation?.Cancel(); + onlineLookupCancellation = null; + + if (beatmapSetInfo.OnlineID < 0) + { + lastLookupResult.Value = BeatmapSetLookupResult.Completed(null); + return; + } + + lastLookupResult.Value = BeatmapSetLookupResult.InProgress(); + onlineLookupCancellation = new CancellationTokenSource(); + currentOnlineLookup = onlineLookupSource.GetBeatmapSetAsync(beatmapSetInfo.OnlineID, onlineLookupCancellation.Token); + currentOnlineLookup.ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + Schedule(() => lastLookupResult.Value = BeatmapSetLookupResult.Completed(t.GetResultSafely())); + + if (t.Exception != null) + { + Logger.Log($"Error when fetching online beatmap set: {t.Exception}", LoggingTarget.Network); + Schedule(() => lastLookupResult.Value = BeatmapSetLookupResult.Completed(null)); + } + }); + } - private bool mouseContained; + #endregion - private InputManager inputManager = null!; + #region Implementation of ISongSelect - public LeftSideInteractionContainer(Action resetCarouselPosition) + void ISongSelect.Search(string query) => FilterControl.Search(query); + + bool ISongSelect.CanPresentScore => true; + + void ISongSelect.PresentScore(ScoreInfo score, ScorePresentType presentType) + { + switch (presentType) { - this.resetCarouselPosition = resetCarouselPosition; + case ScorePresentType.Results: + Debug.Assert(Beatmap.Value.BeatmapInfo.Equals(score.BeatmapInfo)); + Debug.Assert(Ruleset.Value.Equals(score.Ruleset)); + + this.Push(new SoloResultsScreen(score)); + break; + + case ScorePresentType.Gameplay: + (game as OsuGame)?.PresentScore(score, presentType); + break; } + } + + #endregion - // we want to block plain scrolls on the left side so that they don't scroll the carousel, - // but also we *don't* want to handle scrolls when they're combined with keyboard modifiers - // as those will usually correspond to other interactions like adjusting volume. - protected override bool OnScroll(ScrollEvent e) => !e.ControlPressed && !e.AltPressed && !e.ShiftPressed && !e.SuperPressed; + #region IHandlePresentBeatmap - protected override bool OnMouseDown(MouseDownEvent e) => true; + void IHandlePresentBeatmap.PresentBeatmap(WorkingBeatmap workingBeatmap, RulesetInfo ruleset) + { + cancelDebounceSelection(); + + var beatmapInfo = workingBeatmap.BeatmapInfo; + + // Don't change the local ruleset if the user is on another ruleset and is showing converted beatmaps. + // Eventually we probably want to check whether conversion is actually possible for the current ruleset. + bool requiresRulesetSwitch = !beatmapInfo.Ruleset.Equals(Ruleset.Value) + && (beatmapInfo.Ruleset.OnlineID > 0 || !showConvertedBeatmaps.Value); + + if (requiresRulesetSwitch) + { + Ruleset.Value = beatmapInfo.Ruleset; + Beatmap.Value = workingBeatmap; - protected override void LoadComplete() + Logger.Log($"Completing {nameof(IHandlePresentBeatmap.PresentBeatmap)} with beatmap {workingBeatmap} ruleset {beatmapInfo.Ruleset}"); + } + else { - inputManager = GetContainingInputManager()!; - base.LoadComplete(); + Beatmap.Value = workingBeatmap; + + Logger.Log($"Completing {nameof(IHandlePresentBeatmap.PresentBeatmap)} with beatmap {workingBeatmap} (maintaining ruleset)"); } + } + + #endregion - protected override void Update() + #region Beatmap management + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public virtual IEnumerable GetForwardActions(BeatmapInfo beatmap) + { + yield return new OsuMenuItem(GlobalActionKeyBindingStrings.Select, MenuItemType.Highlighted, () => SelectAndRun(beatmap, OnStart)) { - base.Update(); + Icon = FontAwesome.Solid.Check + }; - // We want to trigger an action whenever the cursor is in the left area of song select. - // Other elements in song select handle input, so rather than using `OnHover` let's check the true mouse position. - if (Contains(inputManager.CurrentState.Mouse.Position)) - { - if (!mouseContained) - { - mouseContained = true; - resetCarouselPosition?.Invoke(); - } - } - else - { - mouseContained = false; - } + yield return new OsuMenuItemSpacer(); + + if (beatmap.OnlineID > 0) + { + yield return new OsuMenuItem(CommonStrings.Details, MenuItemType.Standard, () => beatmapOverlay?.FetchAndShowBeatmap(beatmap.OnlineID)); + + if (beatmap.GetOnlineURL(api, Ruleset.Value) is string url) + yield return new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => (game as OsuGame)?.CopyToClipboard(url)); } + + yield return new OsuMenuItemSpacer(); + + foreach (var i in CreateCollectionMenuActions(beatmap)) + yield return i; + } + + protected IEnumerable CreateCollectionMenuActions(BeatmapInfo beatmap) + { + var collectionItems = realm.Realm.All() + .OrderBy(c => c.Name) + .AsEnumerable() + .Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmap)).Cast().ToList(); + + collectionItems.Add(new OsuMenuItem(CommonStrings.Manage, MenuItemType.Standard, () => manageCollectionsDialog?.Show())); + + yield return new OsuMenuItem(CommonStrings.Collections) { Items = collectionItems }; + } + + public void ManageCollections() => collectionsDialog?.Show(); + + public void Delete(BeatmapSetInfo beatmapSet) => dialogOverlay?.Push(new BeatmapDeleteDialog(beatmapSet)); + + public void RestoreAllHidden(BeatmapSetInfo beatmapSet) + { + foreach (var b in beatmapSet.Beatmaps) + beatmaps.Restore(b); + } + + private GroupedBeatmap? beforeScopedSelection; + + private readonly Bindable scopedBeatmapSet = new Bindable(); + public IBindable ScopedBeatmapSet => scopedBeatmapSet; + + public void ScopeToBeatmapSet(BeatmapSetInfo beatmapSet) + { + beforeScopedSelection = carousel.CurrentGroupedBeatmap; + + scopedBeatmapSet.Value = beatmapSet; + } + + public void UnscopeBeatmapSet() + { + if (scopedBeatmapSet.Value == null) + return; + + if (beforeScopedSelection != null) + queueBeatmapSelection(beforeScopedSelection); + + scopedBeatmapSet.Value = null; + beforeScopedSelection = null; + } + + #endregion + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + modSelectOverlayRegistration?.Dispose(); } } } diff --git a/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs b/osu.Game/Screens/Select/UpdateLocalConfirmationDialog.cs similarity index 94% rename from osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs rename to osu.Game/Screens/Select/UpdateLocalConfirmationDialog.cs index 6157e8f6a540..3f847c6816fa 100644 --- a/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs +++ b/osu.Game/Screens/Select/UpdateLocalConfirmationDialog.cs @@ -3,10 +3,10 @@ using System; using osu.Framework.Graphics.Sprites; -using osu.Game.Overlays.Dialog; using osu.Game.Localisation; +using osu.Game.Overlays.Dialog; -namespace osu.Game.Screens.Select.Carousel +namespace osu.Game.Screens.Select { public partial class UpdateLocalConfirmationDialog : DangerousActionDialog { diff --git a/osu.Game/Screens/Select/WedgeBackground.cs b/osu.Game/Screens/Select/WedgeBackground.cs index 2e2b43cd70da..aa1ce025bda0 100644 --- a/osu.Game/Screens/Select/WedgeBackground.cs +++ b/osu.Game/Screens/Select/WedgeBackground.cs @@ -1,37 +1,52 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Shapes; -using osuTK; -using osuTK.Graphics; +using osu.Game.Graphics; +using osu.Game.Overlays; namespace osu.Game.Screens.Select { - public partial class WedgeBackground : Container + internal sealed partial class WedgeBackground : InputBlockingContainer { - public WedgeBackground() + public float StartAlpha { get; init; } = 0.9f; + + public float FinalAlpha { get; init; } = 0.6f; + + public float WidthForGradient { get; init; } = 0.3f; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { - Children = new[] + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] { + new Box + { + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Width = 0.6f, + Alpha = 0.5f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background2, colourProvider.Background2.Opacity(0)), + }, new Box { RelativeSizeAxes = Axes.Both, - Size = new Vector2(1, 0.5f), - Colour = Color4.Black, - Shear = new Vector2(0.15f, 0), - EdgeSmoothness = new Vector2(2, 0), + Width = 1 - WidthForGradient, + Colour = colourProvider.Background5.Opacity(StartAlpha), }, new Box { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Y, - Size = new Vector2(1, -0.5f), - Position = new Vector2(0, 1), - Colour = Color4.Black, - Shear = new Vector2(-0.15f, 0), - EdgeSmoothness = new Vector2(2, 0), + Width = WidthForGradient, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background5.Opacity(StartAlpha), colourProvider.Background5.Opacity(FinalAlpha)), }, }; } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs deleted file mode 100644 index edee63c0fad2..000000000000 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ /dev/null @@ -1,1231 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Pooling; -using osu.Framework.Localisation; -using osu.Framework.Threading; -using osu.Framework.Utils; -using osu.Game.Beatmaps; -using osu.Game.Collections; -using osu.Game.Configuration; -using osu.Game.Database; -using osu.Game.Graphics; -using osu.Game.Graphics.Carousel; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; -using osu.Game.Rulesets; -using osu.Game.Scoring; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Filter; -using Realms; - -namespace osu.Game.Screens.SelectV2 -{ - [Cached] - public partial class BeatmapCarousel : Carousel - { - public Action? RequestPresentBeatmap { private get; init; } - - /// - /// From the provided beatmaps, select the most appropriate one for the user's skill. - /// - public required Action> RequestRecommendedSelection { private get; init; } - - /// - /// Selection requested for the provided beatmap. - /// - public required Action RequestSelection { private get; init; } - - public const float SPACING = 3f; - - private IBindableList detachedBeatmaps = null!; - - private readonly LoadingLayer loading; - - private readonly BeatmapCarouselFilterGrouping grouping; - - /// - /// Total number of beatmap difficulties displayed with the filter. - /// - public int MatchedBeatmapsCount => Filters.Last().BeatmapItemsCount; - - protected override float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom) - { - // Group panels do not overlap with any other panel but should overlap with themselves. - if ((top.Model is GroupDefinition) ^ (bottom.Model is GroupDefinition)) - return SPACING * 2; - - if (grouping.BeatmapSetsGroupedTogether) - { - // Give some space around the expanded beatmap set, at the top.. - if (bottom.Model is GroupedBeatmapSet && bottom.IsExpanded) - return SPACING * 2; - - // ..and the bottom. - if (top.Model is GroupedBeatmap && bottom.Model is GroupedBeatmapSet) - return SPACING * 2; - - // Beatmap difficulty panels do not overlap with themselves or any other panel. - if (top.Model is GroupedBeatmap || bottom.Model is GroupedBeatmap) - return SPACING; - } - else - { - if (CurrentSelection != null && (top == CurrentSelectionItem || bottom == CurrentSelectionItem)) - return SPACING * 2; - } - - return -SPACING; - } - - public BeatmapCarousel() - { - DebounceDelay = 100; - DistanceOffscreenToPreload = 100; - - // Account for the osu! logo being in the way. - Scroll.ScrollbarPaddingBottom = 70; - - Filters = new ICarouselFilter[] - { - new BeatmapCarouselFilterMatching(() => Criteria!), - new BeatmapCarouselFilterSorting(() => Criteria!), - grouping = new BeatmapCarouselFilterGrouping - { - GetCriteria = () => Criteria!, - GetCollections = GetAllCollections, - GetLocalUserTopRanks = GetBeatmapInfoGuidToTopRankMapping, - GetFavouriteBeatmapSets = GetFavouriteBeatmapSets, - } - }; - - AddInternal(loading = new LoadingLayer()); - } - - [BackgroundDependencyLoader] - private void load(BeatmapStore beatmapStore, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken) - { - setupPools(); - detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); - loadSamples(audio); - - config.BindWith(OsuSetting.RandomSelectAlgorithm, randomAlgorithm); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true); - } - - #region Beatmap source hookup - - private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) => Schedule(() => - { - // This callback is scheduled to ensure there's no added overhead during gameplay. - // If this ever becomes an issue, it's important to note that the actual carousel filtering is already - // implemented in a way it will only run when at song select. - // - // The overhead we are avoiding here is that of this method directly – things like Items.IndexOf calls - // that can be slow for very large beatmap libraries. There are definitely ways to optimise this further. - - // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider. - // right now we are managing this locally which is a bit of added overhead. - IEnumerable? newItems = changed.NewItems?.Cast(); - IEnumerable? oldItems = changed.OldItems?.Cast(); - - switch (changed.Action) - { - case NotifyCollectionChangedAction.Add: - if (!newItems!.Any()) - return; - - Items.AddRange(newItems!.SelectMany(s => s.Beatmaps)); - break; - - case NotifyCollectionChangedAction.Remove: - bool selectedSetDeleted = false; - - foreach (var set in oldItems!) - { - foreach (var beatmap in set.Beatmaps) - { - Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi)); - selectedSetDeleted |= CheckModelEquality((CurrentSelection as GroupedBeatmap)?.Beatmap, beatmap); - } - } - - // After removing all items in this batch, we want to make an immediate reselection - // based on adjacency to the previous selection if it was deleted. - // - // This needs to be done immediately to avoid song select making a random selection. - // This needs to be done in this class because we need to know final display order. - // This needs to be done with attention to detail of which beatmaps have not been deleted. - if (selectedSetDeleted && CurrentSelectionIndex != null) - { - var items = GetCarouselItems()!; - if (items.Count == 0) - break; - - bool success = false; - - // Try selecting forwards first - for (int i = CurrentSelectionIndex.Value + 1; i < items.Count; i++) - { - if (attemptSelection(items[i])) - { - success = true; - break; - } - } - - if (success) - break; - - // Then try backwards (we might be at the end of available items). - for (int i = Math.Min(items.Count - 1, CurrentSelectionIndex.Value); i >= 0; i--) - { - if (attemptSelection(items[i])) - break; - } - - bool attemptSelection(CarouselItem item) - { - if (CheckValidForSetSelection(item)) - { - if (item.Model is GroupedBeatmap groupedBeatmap) - { - // check the new selection wasn't deleted above - if (!Items.Contains(groupedBeatmap.Beatmap)) - return false; - - RequestSelection(groupedBeatmap); - return true; - } - - if (item.Model is GroupedBeatmapSet groupedSet) - { - if (oldItems.Contains(groupedSet.BeatmapSet)) - return false; - - selectRecommendedDifficultyForBeatmapSet(groupedSet); - return true; - } - } - - return false; - } - } - - break; - - case NotifyCollectionChangedAction.Move: - // We can ignore move operations as we are applying our own sort in all cases. - break; - - case NotifyCollectionChangedAction.Replace: - var oldSetBeatmaps = oldItems!.Single().Beatmaps; - var newSetBeatmaps = newItems!.Single().Beatmaps.ToList(); - - // Handling replace operations is a touch manual, as we need to locally diff the beatmaps of each version of the beatmap set. - // Matching is done based on online IDs, then difficulty names as these are the most stable thing between updates (which are usually triggered - // by users editing the beatmap or by difficulty/metadata recomputation). - // - // In the case of difficulty reprocessing, this will trigger multiple times per beatmap as it's always triggering a set update. - // We may want to look to improve this in the future either here or at the source (only trigger an update after all difficulties - // have been processed) if it becomes an issue for animation or performance reasons. - foreach (var beatmap in oldSetBeatmaps) - { - int previousIndex = Items.IndexOf(beatmap); - Debug.Assert(previousIndex >= 0); - - // we're intentionally being lenient with there being two difficulties with equal online ID or difficulty name. - // this can be the case when the user modifies the beatmap using the editor's "external edit" feature. - BeatmapInfo? matchingNewBeatmap = - newSetBeatmaps.FirstOrDefault(b => b.OnlineID > 0 && b.OnlineID == beatmap.OnlineID) ?? - newSetBeatmaps.FirstOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset)); - - // The matching beatmap may have been deleted or invalidated in some way since this event was fired. - // Let's make sure we have the most up-to-date realm state. - if (matchingNewBeatmap?.ID is Guid matchingID) - matchingNewBeatmap = realm.Run(r => r.FindWithRefresh(matchingID)?.Detach()); - - if (matchingNewBeatmap != null) - { - // TODO: should this exist in song select instead of here? - // we need to ensure the global beatmap is also updated alongside changes. - if (CurrentBeatmap != null && beatmap.Equals(CurrentBeatmap)) - // we don't know in which group the matching new beatmap is, but that's fine - we can keep the previous one for now. - // we are about to modify `Items`, which - if required - will trigger a re-filter, - // which will pick a correct group - if one is present - via `HandleFilterCompleted()`. - RequestSelection(new GroupedBeatmap(CurrentGroupedBeatmap?.Group, matchingNewBeatmap)); - - Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]); - newSetBeatmaps.Remove(matchingNewBeatmap); - } - else - { - Items.RemoveAt(previousIndex); - } - } - - // Add any items which weren't found in the previous pass (difficulty names didn't match). - foreach (var beatmap in newSetBeatmaps) - Items.Add(beatmap); - - break; - - case NotifyCollectionChangedAction.Reset: - Items.Clear(); - break; - } - }); - - #endregion - - #region Selection handling - - protected GroupDefinition? ExpandedGroup { get; private set; } - - protected GroupedBeatmapSet? ExpandedBeatmapSet { get; private set; } - - protected override bool ShouldActivateOnKeyboardSelection(CarouselItem item) => - grouping.BeatmapSetsGroupedTogether && item.Model is GroupedBeatmap; - - /// - /// The currently selected . - /// - /// - /// The selection is never reset due to not existing. It can be set to anything. - /// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches. - /// - public GroupedBeatmap? CurrentGroupedBeatmap - { - get => CurrentSelection as GroupedBeatmap; - set => CurrentSelection = value; - } - - /// - /// The currently selected . - /// - /// - /// This is a property mostly dedicated to external consumers who only care about showing some particular copy of a beatmap - /// (there could be multiple panels for one beatmap due to grouping). - /// Through this property, the carousel basically figures out what group to use internally. - /// - public BeatmapInfo? CurrentBeatmap - { - get => CurrentGroupedBeatmap?.Beatmap; - set - { - if (value == null) - { - CurrentGroupedBeatmap = null; - return; - } - - if (CurrentGroupedBeatmap != null && value.Equals(CurrentGroupedBeatmap.Beatmap)) - return; - - // it is not universally guaranteed that the carousel items will be materialised at the time this is set. - // therefore, in cases where it is known that they will not be, default to a null group. - // even if grouping is active, this will be rectified to a correct group on the next invocation of `HandleFilterCompleted()`. - CurrentGroupedBeatmap = IsLoaded && !IsFiltering - ? GetCarouselItems()?.Select(item => item.Model).OfType().FirstOrDefault(gb => gb.Beatmap.Equals(value)) - : new GroupedBeatmap(null, value); - } - } - - /// - /// Tracks whether the user has manually requested to collapse an open group. - /// In this case, refilters should not forcibly expand groups until the user expands a group again themselves. - /// - private bool userCollapsedGroup; - - protected override void HandleItemActivated(CarouselItem item) - { - try - { - switch (item.Model) - { - case GroupDefinition group: - // Special case – collapsing an open group. - if (ExpandedGroup == group) - { - setExpansionStateOfGroup(ExpandedGroup, false); - ExpandedGroup = null; - userCollapsedGroup = true; - return; - } - - setExpandedGroup(group); - - if (userCollapsedGroup) - { - if (grouping.BeatmapSetsGroupedTogether && CurrentGroupedBeatmap != null && CheckModelEquality(group, CurrentGroupedBeatmap.Group)) - setExpandedSet(new GroupedBeatmapSet(CurrentGroupedBeatmap.Group, CurrentGroupedBeatmap.Beatmap.BeatmapSet!)); - userCollapsedGroup = false; - } - - // If the active selection is within this group, it should get keyboard focus immediately. - if (CurrentSelectionItem?.IsVisible == true && CurrentSelection is GroupedBeatmap gb) - RequestSelection(gb); - - return; - - case GroupedBeatmapSet groupedSet: - selectRecommendedDifficultyForBeatmapSet(groupedSet); - return; - - case GroupedBeatmap groupedBeatmap: - if (CurrentSelection != null && CheckModelEquality(CurrentSelection, groupedBeatmap)) - { - RequestPresentBeatmap?.Invoke(groupedBeatmap.Beatmap); - return; - } - - RequestSelection(groupedBeatmap); - return; - } - } - finally - { - playActivationSound(item); - } - } - - protected override void HandleItemSelected(object? model) - { - base.HandleItemSelected(model); - - switch (model) - { - case GroupedBeatmapSet: - case GroupDefinition: - throw new InvalidOperationException("Groups should never become selected"); - - case GroupedBeatmap groupedBeatmap: - if (userCollapsedGroup) - break; - - setExpandedGroup(groupedBeatmap.Group); - - if (grouping.BeatmapSetsGroupedTogether) - setExpandedSet(new GroupedBeatmapSet(groupedBeatmap.Group, groupedBeatmap.Beatmap.BeatmapSet!)); - break; - } - } - - protected override bool HandleItemsChanged(NotifyCollectionChangedEventArgs args) - { - switch (args.Action) - { - case NotifyCollectionChangedAction.Add: - case NotifyCollectionChangedAction.Remove: - case NotifyCollectionChangedAction.Move: - case NotifyCollectionChangedAction.Reset: - return true; - - case NotifyCollectionChangedAction.Replace: - var oldBeatmaps = args.OldItems!.OfType().ToList(); - var newBeatmaps = args.NewItems!.OfType().ToList(); - - for (int i = 0; i < oldBeatmaps.Count; i++) - { - var oldBeatmap = oldBeatmaps[i]; - var newBeatmap = newBeatmaps[i]; - - // Ignore changes which don't concern us. - // - // Here are some examples of things that can go wrong: - // - Background difficulty calculation runs and causes a realm update. - // We use `BeatmapDifficultyCache` and don't want to know about these. - // - Background user tag population runs and causes a realm update. - // We don't display user tags so want to ignore this. - bool equalForDisplayPurposes = - // covers metadata changes - oldBeatmap.Hash == newBeatmap.Hash && - // sanity check - oldBeatmap.OnlineID == newBeatmap.OnlineID && - // displayed on panel - oldBeatmap.Status == newBeatmap.Status && - // displayed on panel - oldBeatmap.DifficultyName == newBeatmap.DifficultyName && - // hidden changed, needs re-filter - oldBeatmap.Hidden == newBeatmap.Hidden && - // might be used for grouping, returning from gameplay - oldBeatmap.LastPlayed == newBeatmap.LastPlayed; - - if (equalForDisplayPurposes) - return false; - } - - return true; - - default: - throw new ArgumentOutOfRangeException(); - } - } - - protected override void HandleFilterCompleted() - { - base.HandleFilterCompleted(); - - attemptSelectSingleFilteredResult(); - - // Store selected group before handling selection (it may implicitly change the expanded group). - var groupForReselection = ExpandedGroup; - - var currentGroupedBeatmap = CurrentSelection as GroupedBeatmap; - - // The filter might have changed the set of available groups, which means that the current selection may point to a stale group. - // Check whether that is the case. - bool groupingRemainsOff = currentGroupedBeatmap?.Group == null && grouping.GroupItems.Count == 0; - - bool groupStillValid = false; - - if (currentGroupedBeatmap?.Group != null) - { - groupStillValid = grouping.GroupItems.TryGetValue(currentGroupedBeatmap.Group, out var items) - && items.Any(i => CheckModelEquality(i.Model, currentGroupedBeatmap)); - } - - if (groupingRemainsOff || groupStillValid) - { - // Only update the visual state of the selected item. - HandleItemSelected(currentGroupedBeatmap); - } - else if (currentGroupedBeatmap != null) - { - // If the group no longer exists (or the item no longer exists in the previous group), grab an arbitrary other instance of the beatmap under the first group encountered. - var newSelection = GetCarouselItems()?.Select(i => i.Model).OfType().FirstOrDefault(gb => gb.Beatmap.Equals(currentGroupedBeatmap.Beatmap)); - - // Only change the selection if we actually got a positive hit. - // This is necessary so that selection isn't lost if the panel reappears later due to e.g. unapplying some filter criteria that made it disappear in the first place. - if (newSelection != null) - { - CurrentSelection = newSelection; - groupForReselection = newSelection.Group; - } - } - - // If a group was selected that is not the one containing the selection, attempt to reselect it. - if (groupForReselection != null && grouping.GroupItems.TryGetValue(groupForReselection, out _)) - setExpandedGroup(groupForReselection); - } - - private void selectRecommendedDifficultyForBeatmapSet(GroupedBeatmapSet set) - { - // Selecting a set isn't valid – let's re-select the first visible difficulty. - if (grouping.SetItems.TryGetValue(set, out var items)) - { - var beatmaps = items.Select(i => i.Model).OfType(); - RequestRecommendedSelection(beatmaps); - } - } - - /// - /// If we don't have a selection and there's a single beatmap set returned, select it for the user. - /// - private void attemptSelectSingleFilteredResult() - { - var items = GetCarouselItems(); - - if (items == null || items.Count == 0) return; - - BeatmapSetInfo? beatmapSetInfo = null; - - foreach (var item in items) - { - if (item.Model is GroupedBeatmap groupedBeatmap) - { - var beatmapInfo = groupedBeatmap.Beatmap; - - if (beatmapSetInfo == null) - { - beatmapSetInfo = beatmapInfo.BeatmapSet!; - continue; - } - - // Found a beatmap with a different beatmap set, abort. - if (!beatmapSetInfo.Equals(beatmapInfo.BeatmapSet)) - return; - } - } - - var beatmaps = items.Select(i => i.Model).OfType(); - - // do not request recommended selection if the user already had selected a difficulty within the single filtered beatmap set, - // as it could change the difficulty that will be selected - var preexistingSelection = beatmaps.FirstOrDefault(b => b.Equals(CurrentSelection as GroupedBeatmap)); - - if (preexistingSelection != null) - { - // the selection might not have an item associated with it, if it was fully filtered away previously - // in this case, request to reselect it - if (CurrentSelectionItem == null) - RequestSelection(preexistingSelection); - - return; - } - - RequestRecommendedSelection(beatmaps); - } - - protected override bool CheckValidForGroupSelection(CarouselItem item) => item.Model is GroupDefinition; - - protected override bool CheckValidForSetSelection(CarouselItem item) - { - switch (item.Model) - { - case GroupedBeatmapSet: - return true; - - case GroupedBeatmap: - return !grouping.BeatmapSetsGroupedTogether; - - case GroupDefinition: - return false; - - default: - throw new ArgumentException($"Unsupported model type {item.Model}"); - } - } - - private void setExpandedGroup(GroupDefinition? group) - { - if (ExpandedGroup != null) - setExpansionStateOfGroup(ExpandedGroup, false); - - ExpandedGroup = group; - - if (ExpandedGroup != null) - setExpansionStateOfGroup(ExpandedGroup, true); - } - - private void setExpansionStateOfGroup(GroupDefinition group, bool expanded) - { - if (grouping.GroupItems.TryGetValue(group, out var items)) - { - if (expanded) - { - foreach (var i in items) - { - switch (i.Model) - { - case GroupDefinition: - i.IsExpanded = true; - break; - - case GroupedBeatmapSet groupedSet: - // Case where there are set headers, header should be visible - // and items should use the set's expanded state. - i.IsVisible = true; - setExpansionStateOfSetItems(groupedSet, i.IsExpanded); - break; - - default: - // Case where there are no set headers, all items should be visible. - if (!grouping.BeatmapSetsGroupedTogether) - i.IsVisible = true; - break; - } - } - } - else - { - foreach (var i in items) - { - switch (i.Model) - { - case GroupDefinition: - i.IsExpanded = false; - break; - - default: - i.IsVisible = false; - break; - } - } - } - } - } - - private void setExpandedSet(GroupedBeatmapSet set) - { - if (ExpandedBeatmapSet != null) - setExpansionStateOfSetItems(ExpandedBeatmapSet, false); - ExpandedBeatmapSet = set; - setExpansionStateOfSetItems(ExpandedBeatmapSet, true); - } - - private void setExpansionStateOfSetItems(GroupedBeatmapSet set, bool expanded) - { - if (grouping.SetItems.TryGetValue(set, out var items)) - { - foreach (var i in items) - { - if (i.Model is GroupedBeatmapSet) - i.IsExpanded = expanded; - else - i.IsVisible = expanded; - } - } - } - - public void ExpandGroupForCurrentSelection() - { - if (CurrentGroupedBeatmap?.Group == null) - return; - - if (CheckModelEquality(ExpandedGroup, CurrentGroupedBeatmap.Group)) - return; - - var groupItem = GetCarouselItems()?.FirstOrDefault(i => CheckModelEquality(i.Model, CurrentGroupedBeatmap.Group)); - if (groupItem != null) - Activate(groupItem); - } - - protected override double? GetScrollTarget() - { - double? target = base.GetScrollTarget(); - - // if the base implementation returned null, it means that the keyboard selection has been filtered out and is no longer visible - // attempt a fallback to other possibly expanded panels (set first, then group) - if (target == null) - { - var items = GetCarouselItems(); - var targetItem = items?.FirstOrDefault(i => CheckModelEquality(i.Model, ExpandedBeatmapSet)) - ?? items?.FirstOrDefault(i => CheckModelEquality(i.Model, ExpandedGroup)); - - target = targetItem?.CarouselYPosition; - } - - return target; - } - - #endregion - - #region Audio - - private Sample? sampleChangeDifficulty; - private Sample? sampleChangeSet; - private Sample? sampleToggleGroup; - - private double audioFeedbackLastPlaybackTime; - - private void loadSamples(AudioManager audio) - { - sampleChangeDifficulty = audio.Samples.Get(@"SongSelect/select-difficulty"); - sampleChangeSet = audio.Samples.Get(@"SongSelect/select-expand"); - sampleToggleGroup = audio.Samples.Get(@"SongSelect/select-group"); - - spinSample = audio.Samples.Get("SongSelect/random-spin"); - randomSelectSample = audio.Samples.Get(@"SongSelect/select-random"); - } - - private void playActivationSound(CarouselItem item) - { - if (Time.Current - audioFeedbackLastPlaybackTime >= OsuGameBase.SAMPLE_DEBOUNCE_TIME) - { - switch (item.Model) - { - case GroupDefinition: - sampleToggleGroup?.Play(); - return; - - case GroupedBeatmapSet: - sampleChangeSet?.Play(); - return; - - case GroupedBeatmap: - sampleChangeDifficulty?.Play(); - return; - } - - audioFeedbackLastPlaybackTime = Time.Current; - } - } - - #endregion - - #region Animation - - /// - /// Moves non-selected beatmaps to the right, hiding off-screen. - /// - public bool VisuallyFocusSelected { get; set; } - - private float selectionFocusOffset; - - protected override void Update() - { - base.Update(); - - selectionFocusOffset = (float)Interpolation.DampContinuously(selectionFocusOffset, VisuallyFocusSelected ? 300 : 0, 100, Time.Elapsed); - } - - protected override float GetPanelXOffset(Drawable panel) - { - return base.GetPanelXOffset(panel) + (((ICarouselPanel)panel).Selected.Value ? 0 : selectionFocusOffset); - } - - #endregion - - #region Filtering - - public FilterCriteria? Criteria { get; private set; } - - private ScheduledDelegate? loadingDebounce; - - public void Filter(FilterCriteria criteria, bool showLoadingImmediately = false) - { - bool resetDisplay = grouping.BeatmapSetsGroupedTogether != BeatmapCarouselFilterGrouping.ShouldGroupBeatmapsTogether(criteria); - - Criteria = criteria; - - if (criteria.Group == GroupMode.None) - userCollapsedGroup = false; - - loadingDebounce ??= Scheduler.AddDelayed(() => - { - if (loading.State.Value == Visibility.Visible) - return; - - Scroll.FadeColour(OsuColour.Gray(0.5f), 1000, Easing.OutQuint); - loading.Show(); - }, showLoadingImmediately ? 0 : 250); - - FilterAsync(resetDisplay).ContinueWith(_ => Schedule(() => - { - loadingDebounce?.Cancel(); - loadingDebounce = null; - - Scroll.FadeColour(OsuColour.Gray(1f), 500, Easing.OutQuint); - loading.Hide(); - })); - } - - protected override Task> FilterAsync(bool clearExistingPanels = false) - { - if (Criteria == null) - return Task.FromResult(Enumerable.Empty()); - - return base.FilterAsync(clearExistingPanels); - } - - #endregion - - #region Fetches for grouping support - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - /// - /// FOOTGUN WARNING: this being sorted on the realm side before detaching is IMPORTANT. - /// realm supports sorting as an internal operation, and realm's implementation of string sorting does NOT match dotnet's - /// with respect to treatment of punctuation characters like - or _, among others. - /// All other places that show lists of collections also use the realm-side sorting implementation, - /// because they use the sorting operation inside subscription queries for efficient drawable management, - /// so this usage kind of has to follow suit. - /// - protected virtual List GetAllCollections() => realm.Run(r => r.All().OrderBy(c => c.Name).AsEnumerable().Detach()); - - protected virtual Dictionary GetBeatmapInfoGuidToTopRankMapping(FilterCriteria criteria) => realm.Run(r => - { - var topRankMapping = new Dictionary(); - - var allLocalScores = r.GetAllLocalScoresForUser(criteria.LocalUserId) - .Filter($@"{nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $0", criteria.Ruleset?.ShortName) - .OrderByDescending(s => s.TotalScore) - .ThenBy(s => s.Date); - - foreach (var score in allLocalScores) - { - Debug.Assert(score.BeatmapInfo != null); - - if (topRankMapping.ContainsKey(score.BeatmapInfo.ID)) - continue; - - topRankMapping[score.BeatmapInfo.ID] = score.Rank; - } - - return topRankMapping; - }); - - /// - /// Note that calling .ToHashSet() below has two purposes: - /// one being performance of contain checks in filtering code, - /// another being slightly better thread safety (as could be mutated during async filtering). - /// - protected HashSet GetFavouriteBeatmapSets() => api.LocalUserState.FavouriteBeatmapSets.ToHashSet(); - - #endregion - - #region Drawable pooling - - private readonly DrawablePool beatmapPanelPool = new DrawablePool(100); - private readonly DrawablePool standalonePanelPool = new DrawablePool(100); - private readonly DrawablePool setPanelPool = new DrawablePool(100); - private readonly DrawablePool groupPanelPool = new DrawablePool(100); - private readonly DrawablePool starsGroupPanelPool = new DrawablePool(11); - private readonly DrawablePool ranksGroupPanelPool = new DrawablePool(9); - private readonly DrawablePool statusGroupPanelPool = new DrawablePool(8); - - private void setupPools() - { - AddInternal(statusGroupPanelPool); - AddInternal(ranksGroupPanelPool); - AddInternal(starsGroupPanelPool); - AddInternal(groupPanelPool); - AddInternal(beatmapPanelPool); - AddInternal(standalonePanelPool); - AddInternal(setPanelPool); - } - - protected override bool CheckModelEquality(object? x, object? y) - { - // In the confines of the carousel logic, we assume that CurrentSelection (and all items) are using non-stale - // BeatmapInfo reference, and that we can match based on beatmap / beatmapset (GU)IDs. - // - // If there's a case where updates don't come in as expected, diagnosis should start from BeatmapStore, ensuring - // it is doing a Replace operation on the list. If it is, then check the local handling in beatmapSetsChanged - // before changing matching requirements here. - - if (x is GroupedBeatmapSet groupedSetX && y is GroupedBeatmapSet groupedSetY) - return groupedSetX.Equals(groupedSetY); - - if (x is GroupedBeatmap groupedBeatmapX && y is GroupedBeatmap groupedBeatmapY) - return groupedBeatmapX.Equals(groupedBeatmapY); - - // `BeatmapInfo` is no longer used directly in carousel items, but in rare circumstances still is used for model equality comparisons - // (see `beatmapSetsChanged()` deletion handling logic, which aims to find a beatmap close to the just-deleted one, disregarding grouping concerns) - if (x is BeatmapInfo beatmapInfoX && y is BeatmapInfo beatmapInfoY) - return beatmapInfoX.Equals(beatmapInfoY); - - if (x is GroupDefinition groupX && y is GroupDefinition groupY) - return groupX.Equals(groupY); - - if (x is StarDifficultyGroupDefinition starX && y is StarDifficultyGroupDefinition starY) - return starX.Equals(starY); - - if (x is RankDisplayGroupDefinition rankX && y is RankDisplayGroupDefinition rankY) - return rankX.Equals(rankY); - - if (x is RankedStatusGroupDefinition statusX && y is RankedStatusGroupDefinition statusY) - return statusX.Equals(statusY); - - return base.CheckModelEquality(x, y); - } - - protected override Drawable GetDrawableForDisplay(CarouselItem item) - { - switch (item.Model) - { - case RankedStatusGroupDefinition: - return statusGroupPanelPool.Get(); - - case StarDifficultyGroupDefinition: - return starsGroupPanelPool.Get(); - - case RankDisplayGroupDefinition: - return ranksGroupPanelPool.Get(); - - case GroupDefinition: - return groupPanelPool.Get(); - - case GroupedBeatmap: - if (!grouping.BeatmapSetsGroupedTogether) - return standalonePanelPool.Get(); - - return beatmapPanelPool.Get(); - - case GroupedBeatmapSet: - return setPanelPool.Get(); - } - - throw new InvalidOperationException(); - } - - #endregion - - #region Random selection handling - - private readonly Bindable randomAlgorithm = new Bindable(); - private readonly HashSet previouslyVisitedRandomBeatmaps = new HashSet(); - private readonly List randomHistory = new List(); - - private Sample? spinSample; - private Sample? randomSelectSample; - - public bool NextRandom() - { - var carouselItems = GetCarouselItems(); - - if (carouselItems?.Any() != true) - return false; - - var selectionBefore = CurrentSelectionItem; - var beatmapBefore = selectionBefore?.Model as GroupedBeatmap; - - bool success; - - if (beatmapBefore != null) - { - // keep track of visited beatmaps and sets for rewind - randomHistory.Add(beatmapBefore); - // keep track of visited beatmaps for "RandomPermutation" random tracking. - // note that this is reset when we run out of beatmaps, while `randomHistory` is not. - previouslyVisitedRandomBeatmaps.Add(beatmapBefore.Beatmap); - } - - if (grouping.BeatmapSetsGroupedTogether) - success = nextRandomSet(); - else - success = nextRandomBeatmap(); - - if (!success) - { - if (beatmapBefore != null) - randomHistory.RemoveAt(randomHistory.Count - 1); - return false; - } - - // CurrentSelectionItem won't be valid until UpdateAfterChildren. - // We probably want to fix this at some point since a few places are working-around this quirk. - ScheduleAfterChildren(() => - { - if (selectionBefore != null && CurrentSelectionItem != null) - playSpinSample(visiblePanelCountBetweenItems(selectionBefore, CurrentSelectionItem)); - }); - - return true; - } - - private bool nextRandomBeatmap() - { - ICollection visibleBeatmaps = ExpandedGroup != null - // In the case of grouping, users expect random to only operate on the expanded group. - // This is going to incur some overhead as we don't have a group-beatmapset mapping currently. - // - // If this becomes an issue, we could either store a mapping, or run the random algorithm many times - // using the `SetItems` method until we get a group HIT. - ? grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType().ToArray() - : GetCarouselItems()!.Select(i => i.Model).OfType().ToArray(); - - GroupedBeatmap beatmap; - - switch (randomAlgorithm.Value) - { - case RandomSelectAlgorithm.RandomPermutation: - { - ICollection notYetVisitedBeatmaps = visibleBeatmaps.ExceptBy(previouslyVisitedRandomBeatmaps, gb => gb.Beatmap).ToList(); - - if (!notYetVisitedBeatmaps.Any()) - { - previouslyVisitedRandomBeatmaps.ExceptWith(visibleBeatmaps.Select(b => b.Beatmap)); - notYetVisitedBeatmaps = visibleBeatmaps; - if (CurrentSelection is GroupedBeatmap groupedBeatmap) - notYetVisitedBeatmaps = notYetVisitedBeatmaps.Except([groupedBeatmap]).ToList(); - } - - if (notYetVisitedBeatmaps.Count == 0) - return false; - - beatmap = notYetVisitedBeatmaps.ElementAt(RNG.Next(notYetVisitedBeatmaps.Count)); - break; - } - - case RandomSelectAlgorithm.Random: - beatmap = visibleBeatmaps.ElementAt(RNG.Next(visibleBeatmaps.Count)); - break; - - default: - throw new ArgumentOutOfRangeException(); - } - - RequestSelection(beatmap); - return true; - } - - private bool nextRandomSet() - { - ICollection visibleGroupedSets = ExpandedGroup != null && grouping.GroupItems.TryGetValue(ExpandedGroup, out var groupItems) - // In the case of grouping, users expect random to only operate on the expanded group. - // This is going to incur some overhead as we don't have a group-beatmapset mapping currently. - // - // If this becomes an issue, we could either store a mapping, or run the random algorithm many times - // using the `SetItems` method until we get a group HIT. - ? groupItems.Select(i => i.Model).OfType().ToArray() - // This is the fastest way to retrieve sets for randomisation. - : grouping.SetItems.Keys; - - GroupedBeatmapSet set; - - switch (randomAlgorithm.Value) - { - case RandomSelectAlgorithm.RandomPermutation: - { - ICollection notYetVisitedSets = - visibleGroupedSets.ExceptBy(previouslyVisitedRandomBeatmaps.Select(b => b.BeatmapSet!), groupedSet => groupedSet.BeatmapSet).ToList(); - - if (!notYetVisitedSets.Any()) - { - previouslyVisitedRandomBeatmaps.ExceptWith(visibleGroupedSets.SelectMany(setUnderGrouping => setUnderGrouping.BeatmapSet.Beatmaps)); - notYetVisitedSets = visibleGroupedSets; - if (CurrentSelection is GroupedBeatmap groupedBeatmap) - notYetVisitedSets = notYetVisitedSets.ExceptBy([groupedBeatmap.Beatmap.BeatmapSet!], groupedSet => groupedSet.BeatmapSet).ToList(); - } - - if (notYetVisitedSets.Count == 0) - return false; - - set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count)); - break; - } - - case RandomSelectAlgorithm.Random: - set = visibleGroupedSets.ElementAt(RNG.Next(visibleGroupedSets.Count)); - break; - - default: - throw new ArgumentOutOfRangeException(); - } - - selectRecommendedDifficultyForBeatmapSet(set); - return true; - } - - public bool PreviousRandom() - { - var carouselItems = GetCarouselItems(); - - if (carouselItems?.Any() != true) - return false; - - while (randomHistory.Any()) - { - var previousBeatmap = randomHistory[^1]; - randomHistory.RemoveAt(randomHistory.Count - 1); - - // when going back through rewind history, we may no longer be in the same grouping mode. - // the user wants to go back to the beatmap first and foremost, so the most important thing is to find a panel that corresponds to the beatmap. - // going back to the same group is a nice-to-have, but a secondary concern. - var previousBeatmapItem = carouselItems.Where(i => i.Model is GroupedBeatmap gb && gb.Beatmap.Equals(previousBeatmap.Beatmap)) - .MaxBy(i => ((GroupedBeatmap)i.Model).Group == previousBeatmap.Group); - - if (previousBeatmapItem == null) - return false; - - if (CurrentSelection is GroupedBeatmap groupedBeatmap) - { - if (randomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) - previouslyVisitedRandomBeatmaps.Remove(groupedBeatmap.Beatmap); - - if (CurrentSelectionItem == null) - playSpinSample(0); - else - playSpinSample(visiblePanelCountBetweenItems(previousBeatmapItem, CurrentSelectionItem)); - } - - RequestSelection((GroupedBeatmap)previousBeatmapItem.Model); - return true; - } - - return false; - } - - private double visiblePanelCountBetweenItems(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / PanelBeatmapSet.HEIGHT); - - private void playSpinSample(double distance) - { - var chan = spinSample?.GetChannel(); - - if (chan != null) - { - chan.Frequency.Value = 1f + Math.Clamp(distance / 200, 0, 1); - chan.Play(); - } - - randomSelectSample?.Play(); - } - - #endregion - } - - /// - /// Defines a grouping header for a set of carousel items. - /// - public record GroupDefinition - { - /// - /// The order of this group in the carousel, sorted using ascending order. - /// - public int Order { get; } - - /// - /// The title of this group. - /// - public LocalisableString Title { get; } - - private readonly string uncasedTitle; - - public GroupDefinition(int order, LocalisableString title) - { - Order = order; - Title = title; - uncasedTitle = title.ToLower().GetLocalised(LocalisationParameters.DEFAULT); - } - - public virtual bool Equals(GroupDefinition? other) => uncasedTitle == other?.uncasedTitle; - - public override int GetHashCode() => HashCode.Combine(uncasedTitle); - } - - /// - /// Defines a grouping header for a set of carousel items grouped by star difficulty. - /// - public record StarDifficultyGroupDefinition(int Order, LocalisableString Title, StarDifficulty Difficulty) : GroupDefinition(Order, Title); - - /// - /// Defines a grouping header for a set of carousel items grouped by achieved rank. - /// - public record RankDisplayGroupDefinition(ScoreRank Rank) : GroupDefinition(-(int)Rank, Rank.GetLocalisableDescription()); - - /// - /// Defines a grouping header for a set of carousel items grouped by ranked status. - /// - public record RankedStatusGroupDefinition(int Order, BeatmapOnlineStatus Status) : GroupDefinition(Order, Status.GetLocalisableDescription()); - - /// - /// Used to represent a portion of a under a . - /// The purpose of this model is to support splitting beatmap sets apart when the active grouping mode demands it. - /// - public record GroupedBeatmapSet([UsedImplicitly] GroupDefinition? Group, BeatmapSetInfo BeatmapSet); - - /// - /// Used to represent a under a . - /// The purpose of this model is to support showing multiple copies of a beatmap, which can occur if a beatmap appears in multiple groups - /// (most prominently, collections group mode). - /// - public record GroupedBeatmap(GroupDefinition? Group, BeatmapInfo Beatmap); -} diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs deleted file mode 100644 index a90ac3a4e865..000000000000 --- a/osu.Game/Screens/SelectV2/FilterControl.cs +++ /dev/null @@ -1,335 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Input; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Collections; -using osu.Game.Configuration; -using osu.Game.Database; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Localisation; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Filter; -using osuTK; -using osuTK.Input; - -namespace osu.Game.Screens.SelectV2 -{ - public partial class FilterControl : OverlayContainer - { - // taken from draw visualiser. used for carousel alignment purposes. - public const float HEIGHT_FROM_SCREEN_TOP = 141 - corner_radius; - - private const float corner_radius = 10; - - private SongSelectSearchTextBox searchTextBox = null!; - private ShearedToggleButton showConvertedBeatmapsButton = null!; - private DifficultyRangeSlider difficultyRangeSlider = null!; - private ShearedDropdown sortDropdown = null!; - private ShearedDropdown groupDropdown = null!; - private CollectionDropdown collectionDropdown = null!; - - [Resolved] - private IBindable ruleset { get; set; } = null!; - - [Resolved] - private IBindable> mods { get; set; } = null!; - - [Resolved] - private OsuConfigManager config { get; set; } = null!; - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - private IBindable localUser = null!; - private readonly IBindableList localUserFavouriteBeatmapSets = new BindableList(); - - public LocalisableString StatusText - { - get => searchTextBox.StatusText; - set => searchTextBox.StatusText = value; - } - - public event Action? CriteriaChanged; - - private FilterCriteria currentCriteria = null!; - - private IDisposable? collectionsSubscription; - - [BackgroundDependencyLoader] - private void load(IAPIProvider api) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - Shear = OsuGame.SHEAR; - Margin = new MarginPadding { Top = -corner_radius, Right = -40 }; - - InternalChildren = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - CornerRadius = corner_radius, - Masking = true, - Child = new WedgeBackground - { - Anchor = Anchor.TopRight, - Scale = new Vector2(-1, 1), - } - }, - new ReverseChildIDFillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0f, 5f), - Padding = new MarginPadding { Top = corner_radius + 5, Bottom = 2, Right = 40f, Left = 2f }, - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Shear = -OsuGame.SHEAR, - Child = searchTextBox = new SongSelectSearchTextBox - { - RelativeSizeAxes = Axes.X, - HoldFocus = true, - }, - }, - new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Shear = -OsuGame.SHEAR, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute), // can probably be removed? - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new[] - { - difficultyRangeSlider = new DifficultyRangeSlider - { - RelativeSizeAxes = Axes.X, - MinRange = 0.1f, - }, - Empty(), - showConvertedBeatmapsButton = new ShearedToggleButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = UserInterfaceStrings.ShowConverts, - Height = 30f, - }, - }, - } - }, - new GridContainer - { - RelativeSizeAxes = Axes.X, - Height = 30, - Shear = -OsuGame.SHEAR, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - ColumnDimensions = new[] - { - new Dimension(maxSize: 180), - new Dimension(GridSizeMode.Absolute, 5), - new Dimension(maxSize: 180), - new Dimension(GridSizeMode.Absolute, 5), - new Dimension(), - }, - Content = new[] - { - new[] - { - sortDropdown = new ShearedDropdown(SongSelectStrings.Sort) - { - RelativeSizeAxes = Axes.X, - Items = Enum.GetValues(), - }, - Empty(), - groupDropdown = new ShearedDropdown(SongSelectStrings.Group) - { - RelativeSizeAxes = Axes.X, - Items = Enum.GetValues(), - }, - Empty(), - collectionDropdown = new CollectionDropdown - { - RelativeSizeAxes = Axes.X, - }, - } - } - }, - }, - } - }; - - localUser = api.LocalUser.GetBoundCopy(); - localUserFavouriteBeatmapSets.BindTo(api.LocalUserState.FavouriteBeatmapSets); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - difficultyRangeSlider.LowerBound = config.GetBindable(OsuSetting.DisplayStarsMinimum); - difficultyRangeSlider.UpperBound = config.GetBindable(OsuSetting.DisplayStarsMaximum); - config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConvertedBeatmapsButton.Active); - config.BindWith(OsuSetting.SongSelectSortingMode, sortDropdown.Current); - config.BindWith(OsuSetting.SongSelectGroupMode, groupDropdown.Current); - - ruleset.BindValueChanged(_ => updateCriteria()); - mods.BindValueChanged(m => - { - // The following is a note carried from old song select and may not be a valid reason anymore: - // // Mods are updated once by the mod select overlay when song select is entered, - // // regardless of if there are any mods or any changes have taken place. - // // Updating the criteria here so early triggers a re-ordering of panels on song select, via... some mechanism. - // // Todo: Investigate/fix and potentially remove this. - // TODO: this might be simply removable with the new song select & carousel code. - if (m.NewValue.SequenceEqual(m.OldValue)) - return; - - var rulesetCriteria = currentCriteria.RulesetCriteria; - if (rulesetCriteria?.FilterMayChangeFromMods(m) == true) - updateCriteria(); - }); - - searchTextBox.Current.BindValueChanged(_ => updateCriteria()); - difficultyRangeSlider.LowerBound.BindValueChanged(_ => updateCriteria()); - difficultyRangeSlider.UpperBound.BindValueChanged(_ => updateCriteria()); - showConvertedBeatmapsButton.Active.BindValueChanged(_ => updateCriteria()); - sortDropdown.Current.BindValueChanged(_ => updateCriteria()); - groupDropdown.Current.BindValueChanged(_ => updateCriteria()); - collectionDropdown.Current.BindValueChanged(v => - { - // The hope would be that this never arrives here, but due to bindings receiving changes before - // local ValueChanged events, that's not the case (see https://github.com/ppy/osu-framework/pull/1545). - if (v.NewValue is ManageCollectionsFilterMenuItem || v.OldValue is ManageCollectionsFilterMenuItem) - return; - - updateCriteria(); - }); - collectionsSubscription = realm.RegisterForNotifications(r => r.All(), (collections, changeSet) => - { - if (changeSet != null && groupDropdown.Current.Value == GroupMode.Collections) - updateCriteria(); - }); - - localUser.BindValueChanged(_ => updateCriteria()); - localUserFavouriteBeatmapSets.BindCollectionChanged((_, _) => updateCriteria()); - - updateCriteria(); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - collectionsSubscription?.Dispose(); - } - - /// - /// Creates a based on the current state of the controls. - /// - public FilterCriteria CreateCriteria() - { - string query = searchTextBox.Current.Value; - bool isValidUser = localUser.Value.Id > 1; - - var criteria = new FilterCriteria - { - Sort = sortDropdown.Current.Value, - Group = groupDropdown.Current.Value, - AllowConvertedBeatmaps = showConvertedBeatmapsButton.Active.Value, - Ruleset = ruleset.Value, - Mods = mods.Value, - CollectionBeatmapMD5Hashes = collectionDropdown.Current.Value?.Collection?.PerformRead(c => c.BeatmapMD5Hashes).ToImmutableHashSet(), - LocalUserId = isValidUser ? localUser.Value.Id : null, - LocalUserUsername = isValidUser ? localUser.Value.Username : null, - }; - - if (!difficultyRangeSlider.LowerBound.IsDefault) - criteria.UserStarDifficulty.Min = difficultyRangeSlider.LowerBound.Value; - - if (!difficultyRangeSlider.UpperBound.IsDefault) - criteria.UserStarDifficulty.Max = difficultyRangeSlider.UpperBound.Value; - - criteria.RulesetCriteria = ruleset.Value.CreateInstance().CreateRulesetFilterCriteria(); - - FilterQueryParser.ApplyQueries(criteria, query); - return criteria; - } - - private void updateCriteria() - { - currentCriteria = CreateCriteria(); - CriteriaChanged?.Invoke(currentCriteria); - } - - /// - /// Set the query to the search text box. - /// - /// The string to search. - public void Search(string query) - { - searchTextBox.Current.Value = query; - } - - protected override void PopIn() - { - this.MoveToX(0, SongSelect.ENTER_DURATION, Easing.OutQuint) - .FadeIn(SongSelect.ENTER_DURATION / 3, Easing.In); - } - - protected override void PopOut() - { - this.MoveToX(150, SongSelect.ENTER_DURATION, Easing.OutQuint) - .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); - } - - internal partial class SongSelectSearchTextBox : ShearedFilterTextBox - { - protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox(); - - private partial class InnerTextBox : InnerFilterTextBox - { - public override bool HandleLeftRightArrows => false; - - public override bool OnPressed(KeyBindingPressEvent e) - { - // Conflicts with default group navigation keys (shift-left shift-right). - if (e.Action == PlatformAction.SelectBackwardChar || e.Action == PlatformAction.SelectForwardChar) - return false; - - // the "cut" platform key binding (shift-delete) conflicts with the beatmap deletion action. - if (e.Action == PlatformAction.Cut && e.ShiftPressed && e.CurrentState.Keyboard.Keys.IsPressed(Key.Delete)) - return false; - - return base.OnPressed(e); - } - } - } - } -} diff --git a/osu.Game/Screens/SelectV2/FooterButtonMods.cs b/osu.Game/Screens/SelectV2/FooterButtonMods.cs deleted file mode 100644 index 4720c1173179..000000000000 --- a/osu.Game/Screens/SelectV2/FooterButtonMods.cs +++ /dev/null @@ -1,383 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Localisation; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Footer; -using osu.Game.Screens.Play.HUD; -using osu.Game.Utils; -using osuTK; -using osuTK.Graphics; -using osuTK.Input; - -namespace osu.Game.Screens.SelectV2 -{ - public partial class FooterButtonMods : ScreenFooterButton, IHasCurrentValue> - { - public Action? RequestDeselectAllMods { get; init; } - - private const float bar_height = 30f; - private const float mod_display_portion = 0.65f; - - private readonly BindableWithCurrent> current = new BindableWithCurrent>(Array.Empty()); - - public Bindable> Current - { - get => current.Current; - set => current.Current = value; - } - - private Container modDisplayBar = null!; - - private Drawable unrankedBadge = null!; - - private ModDisplay modDisplay = null!; - - private OsuSpriteText multiplierText { get; set; } = null!; - - private Container modContainer = null!; - - private ModCountText overflowModCountDisplay = null!; - - [Resolved] - private OsuColour colours { get; set; } = null!; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - public FooterButtonMods(ModSelectOverlay overlay) - : base(overlay) - { - } - - [BackgroundDependencyLoader] - private void load() - { - Text = SongSelectStrings.Mods; - Icon = FontAwesome.Solid.ExchangeAlt; - AccentColour = colours.Lime1; - - AddRange(new[] - { - unrankedBadge = new UnrankedBadge(), - modDisplayBar = new Container - { - Y = -5f, - Depth = float.MaxValue, - Origin = Anchor.BottomLeft, - Shear = OsuGame.SHEAR, - CornerRadius = CORNER_RADIUS, - Size = new Vector2(BUTTON_WIDTH, bar_height), - Masking = true, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 4, - // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. - Colour = Colour4.Black.Opacity(0.25f), - Offset = new Vector2(0, 2), - }, - Children = new Drawable[] - { - new Box - { - Colour = colourProvider.Background4, - RelativeSizeAxes = Axes.Both, - }, - new Container - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Both, - Width = 1f - mod_display_portion, - Masking = true, - Child = multiplierText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shear = -OsuGame.SHEAR, - UseFullGlyphHeight = false, - Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold) - } - }, - modContainer = new Container - { - CornerRadius = CORNER_RADIUS, - RelativeSizeAxes = Axes.Both, - Width = mod_display_portion, - Masking = true, - Children = new Drawable[] - { - new Box - { - Colour = colourProvider.Background3, - RelativeSizeAxes = Axes.Both, - }, - modDisplay = new ModDisplay(showExtendedInformation: true) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shear = -OsuGame.SHEAR, - Scale = new Vector2(0.5f), - Current = { BindTarget = Current }, - ExpansionMode = ExpansionMode.AlwaysContracted, - }, - overflowModCountDisplay = new ModCountText { Mods = { BindTarget = Current }, }, - } - }, - } - }, - }); - } - - private ModSettingChangeTracker? modSettingChangeTracker; - - protected override void LoadComplete() - { - base.LoadComplete(); - - Current.BindValueChanged(m => - { - modSettingChangeTracker?.Dispose(); - - updateDisplay(); - - if (m.NewValue != null) - { - modSettingChangeTracker = new ModSettingChangeTracker(m.NewValue); - modSettingChangeTracker.SettingChanged += _ => updateDisplay(); - } - }, true); - - FinishTransforms(true); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - // should probably be OnClick but right mouse button clicks isn't setup well. - if (e.Button == MouseButton.Right) - { - RequestDeselectAllMods?.Invoke(); - return true; - } - - return base.OnMouseDown(e); - } - - private const double duration = 240; - private const Easing easing = Easing.OutQuint; - - private void updateDisplay() - { - if (Current.Value.Count == 0) - { - modDisplayBar.MoveToY(20, duration, easing); - modDisplayBar.FadeOut(duration, easing); - modDisplay.FadeOut(duration, easing); - overflowModCountDisplay.FadeOut(duration, easing); - - unrankedBadge.MoveToY(20, duration, easing); - unrankedBadge.FadeOut(duration, easing); - - // add delay to let unranked indicator hide first before resizing the button back to its original width. - this.Delay(duration).ResizeWidthTo(BUTTON_WIDTH, duration, easing); - } - else - { - if (Current.Value.Any(m => !m.Ranked)) - { - unrankedBadge.MoveToX(0, duration, easing); - unrankedBadge.FadeIn(duration, easing); - - this.ResizeWidthTo(BUTTON_WIDTH + 5 + unrankedBadge.DrawWidth, duration, easing); - } - else - { - unrankedBadge.MoveToX(-unrankedBadge.DrawWidth, duration, easing); - unrankedBadge.FadeOut(duration, easing); - - this.ResizeWidthTo(BUTTON_WIDTH, duration, easing); - } - - modDisplayBar.MoveToY(-5, duration, Easing.OutQuint); - unrankedBadge.MoveToY(-5, duration, easing); - modDisplayBar.FadeIn(duration, easing); - modDisplay.FadeIn(duration, easing); - } - - double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1; - multiplierText.Text = ModUtils.FormatScoreMultiplier(multiplier); - - if (multiplier > 1) - multiplierText.FadeColour(colours.Red1, duration, easing); - else if (multiplier < 1) - multiplierText.FadeColour(colours.Lime1, duration, easing); - else - multiplierText.FadeColour(Color4.White, duration, easing); - } - - protected override void Update() - { - base.Update(); - - if (Current.Value.Count == 0) - return; - - if (modDisplay.DrawWidth * modDisplay.Scale.X > modContainer.DrawWidth) - overflowModCountDisplay.Show(); - else - overflowModCountDisplay.Hide(); - } - - private partial class ModCountText : CompositeDrawable, IHasCustomTooltip> - { - public readonly Bindable> Mods = new Bindable>(); - - private OsuSpriteText text = null!; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - protected override void LoadComplete() - { - base.LoadComplete(); - - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new Box - { - Colour = colourProvider.Background3, - Alpha = 0.8f, - RelativeSizeAxes = Axes.Both, - }, - text = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), - Shear = -OsuGame.SHEAR, - } - }; - - Mods.BindValueChanged(v => text.Text = ModSelectOverlayStrings.Mods(v.NewValue.Count).ToUpper(), true); - } - - public ITooltip> GetCustomTooltip() => new ModOverflowTooltip(colourProvider); - - public IReadOnlyList? TooltipContent => Mods.Value; - - public partial class ModOverflowTooltip : VisibilityContainer, ITooltip> - { - private ModDisplay extendedModDisplay = null!; - - [Cached] - private OverlayColourProvider colourProvider; - - public ModOverflowTooltip(OverlayColourProvider colourProvider) - { - this.colourProvider = colourProvider; - } - - [BackgroundDependencyLoader] - private void load() - { - AutoSizeAxes = Axes.Both; - CornerRadius = CORNER_RADIUS; - Masking = true; - - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, - extendedModDisplay = new ModDisplay - { - Margin = new MarginPadding { Vertical = 2f, Horizontal = 10f }, - Scale = new Vector2(0.6f), - ExpansionMode = ExpansionMode.AlwaysExpanded, - }, - }; - } - - public void SetContent(IReadOnlyList content) - { - extendedModDisplay.Current.Value = content; - } - - public void Move(Vector2 pos) => Position = pos; - - protected override void PopIn() => this.FadeIn(240, Easing.OutQuint); - protected override void PopOut() => this.FadeOut(240, Easing.OutQuint); - } - } - - internal partial class UnrankedBadge : CompositeDrawable, IHasTooltip - { - public LocalisableString TooltipText { get; } - - public UnrankedBadge() - { - Margin = new MarginPadding { Left = BUTTON_WIDTH + 5f }; - Y = -5f; - Depth = float.MaxValue; - Origin = Anchor.BottomLeft; - Shear = OsuGame.SHEAR; - CornerRadius = CORNER_RADIUS; - AutoSizeAxes = Axes.X; - Height = bar_height; - Masking = true; - BorderColour = Color4.White; - BorderThickness = 2f; - TooltipText = ModSelectOverlayStrings.UnrankedExplanation; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - InternalChildren = new Drawable[] - { - new Box - { - Colour = colours.Orange2, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shear = -OsuGame.SHEAR, - Text = ModSelectOverlayStrings.Unranked.ToUpper(), - Margin = new MarginPadding { Horizontal = 15 }, - UseFullGlyphHeight = false, - Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), - Colour = Color4.Black, - } - }; - } - } - } -} diff --git a/osu.Game/Screens/SelectV2/FooterButtonOptions.cs b/osu.Game/Screens/SelectV2/FooterButtonOptions.cs deleted file mode 100644 index 4da40559e98a..000000000000 --- a/osu.Game/Screens/SelectV2/FooterButtonOptions.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Graphics; -using osu.Game.Input.Bindings; -using osu.Game.Localisation; -using osu.Game.Overlays; -using osu.Game.Screens.Footer; - -namespace osu.Game.Screens.SelectV2 -{ - public partial class FooterButtonOptions : ScreenFooterButton, IHasPopover - { - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - [Resolved] - private IBindable workingBeatmap { get; set; } = null!; - - [Resolved] - private ISongSelect? songSelect { get; set; } - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - private Live beatmap = null!; - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - Text = SongSelectStrings.Options; - Icon = FontAwesome.Solid.Cog; - AccentColour = colour.Purple1; - Hotkey = GlobalAction.ToggleBeatmapOptions; - - Action = this.ShowPopover; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - workingBeatmap.BindValueChanged(_ => beatmapChanged(), true); - } - - private void beatmapChanged() - { - this.HidePopover(); - Enabled.Value = !workingBeatmap.IsDefault; - if (!workingBeatmap.IsDefault) - beatmap = realm.Run(r => r.Find(workingBeatmap.Value.BeatmapInfo.ID)!.ToLive(realm)); - } - - public Framework.Graphics.UserInterface.Popover GetPopover() => new Popover(this, beatmap.Value.Detach()) - { - ColourProvider = colourProvider, - SongSelect = songSelect - }; - } -} diff --git a/osu.Game/Screens/SelectV2/FooterButtonRandom.cs b/osu.Game/Screens/SelectV2/FooterButtonRandom.cs deleted file mode 100644 index 4bd42497ebf1..000000000000 --- a/osu.Game/Screens/SelectV2/FooterButtonRandom.cs +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Input.Bindings; -using osu.Game.Localisation; -using osu.Game.Screens.Footer; -using osuTK; -using osuTK.Input; - -namespace osu.Game.Screens.SelectV2 -{ - public partial class FooterButtonRandom : ScreenFooterButton - { - public Action? NextRandom { get; set; } - public Action? PreviousRandom { get; set; } - - private Container persistentText = null!; - private OsuSpriteText randomSpriteText = null!; - private OsuSpriteText rewindSpriteText = null!; - private bool rewindSearch; - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - //TODO: use https://fontawesome.com/icons/shuffle?s=solid&f=classic when local Fontawesome is updated - Icon = FontAwesome.Solid.Random; - AccentColour = colour.Blue1; - TextContainer.Add(persistentText = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AlwaysPresent = true, - AutoSizeAxes = Axes.Both, - Children = new[] - { - randomSpriteText = new OsuSpriteText - { - Font = OsuFont.TorusAlternate.With(size: 16), - AlwaysPresent = true, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = SongSelectStrings.Random, - }, - rewindSpriteText = new OsuSpriteText - { - Font = OsuFont.TorusAlternate.With(size: 16), - AlwaysPresent = true, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = SongSelectStrings.Rewind, - Alpha = 0f, - } - } - }); - - Action = () => - { - if (rewindSearch) - { - const double fade_time = 500; - - OsuSpriteText fallingRewind; - - TextContainer.Add(fallingRewind = new OsuSpriteText - { - Alpha = 0, - Text = rewindSpriteText.Text, - AlwaysPresent = true, // make sure the button is sized large enough to always show this - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Font = OsuFont.TorusAlternate.With(size: 16), - }); - - fallingRewind.FadeOutFromOne(fade_time, Easing.In); - fallingRewind.MoveTo(Vector2.Zero).MoveTo(new Vector2(0, 10), fade_time, Easing.In); - fallingRewind.Expire(); - - persistentText.FadeInFromZero(fade_time, Easing.In); - - PreviousRandom?.Invoke(); - } - else - { - NextRandom?.Invoke(); - } - }; - } - - protected override bool OnKeyDown(KeyDownEvent e) - { - updateText(e.ShiftPressed); - return base.OnKeyDown(e); - } - - protected override void OnKeyUp(KeyUpEvent e) - { - updateText(e.ShiftPressed); - base.OnKeyUp(e); - } - - protected override bool OnClick(ClickEvent e) - { - try - { - // this uses OR to handle rewinding when clicks are triggered by other sources (i.e. right button in OnMouseUp). - rewindSearch |= e.ShiftPressed; - return base.OnClick(e); - } - finally - { - rewindSearch = false; - } - } - - protected override void OnMouseUp(MouseUpEvent e) - { - if (e.Button == MouseButton.Right && IsHovered) - { - rewindSearch = true; - TriggerClick(); - return; - } - - base.OnMouseUp(e); - } - - public override bool OnPressed(KeyBindingPressEvent e) - { - rewindSearch = e.Action == GlobalAction.SelectPreviousRandom; - - if (e.Action != GlobalAction.SelectNextRandom && e.Action != GlobalAction.SelectPreviousRandom) - { - return false; - } - - if (!e.Repeat) - TriggerClick(); - return true; - } - - public override void OnReleased(KeyBindingReleaseEvent e) - { - if (e.Action == GlobalAction.SelectPreviousRandom) - { - rewindSearch = false; - } - } - - private void updateText(bool rewind = false) - { - randomSpriteText.Alpha = rewind ? 0 : 1; - rewindSpriteText.Alpha = rewind ? 1 : 0; - } - } -} diff --git a/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs b/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs deleted file mode 100644 index 597b6de8512f..000000000000 --- a/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Localisation; -using osu.Game.Online.Chat; -using osu.Game.Overlays; -using osu.Game.Screens.Select; -using osuTK; - -namespace osu.Game.Screens.SelectV2 -{ - public partial class NoResultsPlaceholder : VisibilityContainer - { - public Action? RequestClearFilterText { get; init; } - - private FilterCriteria? filter; - - private LinkFlowContainer textFlow = null!; - - private GhostIcon icon = null!; - - [Resolved] - private BeatmapManager beatmaps { get; set; } = null!; - - [Resolved] - private FirstRunSetupOverlay? firstRunSetupOverlay { get; set; } - - [Resolved] - private OsuConfigManager config { get; set; } = null!; - - protected override bool StartHidden => true; - - public FilterCriteria Filter - { - set - { - if (filter == value) - return; - - filter = value; - Scheduler.AddOnce(updateText); - } - } - - [BackgroundDependencyLoader] - private void load() - { - RelativeSizeAxes = Axes.Both; - - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - Direction = FillDirection.Vertical, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 300, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding(10), - Size = new Vector2(50), - Child = icon = new GhostIcon - { - RelativeSizeAxes = Axes.Both, - }, - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Font = OsuFont.Style.Title, - Text = SongSelectStrings.NoMatchingBeatmaps - }, - textFlow = new LinkFlowContainer - { - Alpha = 0, - AlwaysPresent = true, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Padding = new MarginPadding { Top = 20 }, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - } - } - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - icon.Loop(t => - t.MoveToY(-10, 2000, Easing.InOutSine) - .Then() - .MoveToY(0, 2000, Easing.InOutSine) - ); - } - - protected override void PopIn() - { - this.FadeIn(600, Easing.OutQuint); - - Scheduler.AddOnce(updateText); - } - - protected override void PopOut() - { - this.FadeOut(200, Easing.OutQuint); - } - - private void updateText() - { - // TODO: Refresh this text when new beatmaps are imported. Right now it won't get up-to-date suggestions. - - // Bounce should play every time the filter criteria is updated. - this.ScaleTo(0.9f) - .ScaleTo(1f, 1000, Easing.OutQuint); - - textFlow.FadeInFromZero(800, Easing.OutQuint); - - textFlow.Clear(); - - if (beatmaps.QueryBeatmapSet(s => !s.Protected && !s.DeletePending) == null) - { - addBulletPoint(); - textFlow.AddText("Consider running the \""); - textFlow.AddLink(FirstRunSetupOverlayStrings.FirstRunSetupTitle, () => firstRunSetupOverlay?.Show()); - textFlow.AddText("\" to download or import some beatmaps!"); - } - else - { - textFlow.AddParagraph(SongSelectStrings.NoMatchingBeatmapsDescription); - textFlow.AddParagraph(string.Empty); - - if (!string.IsNullOrEmpty(filter?.SearchText)) - { - addBulletPoint(); - textFlow.AddText("Try "); - textFlow.AddLink("clearing", () => - { - RequestClearFilterText?.Invoke(); - }); - - textFlow.AddText(" your current search criteria."); - } - - if (filter?.UserStarDifficulty.HasFilter == true) - { - addBulletPoint(); - textFlow.AddText("Try "); - textFlow.AddLink("removing", () => - { - config.SetValue(OsuSetting.DisplayStarsMinimum, 0.0); - config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1); - }); - - string lowerStar = $"{filter.UserStarDifficulty.Min ?? 0:N1}"; - string upperStar = filter.UserStarDifficulty.Max == null ? "∞" : $"{filter.UserStarDifficulty.Max:N1}"; - - textFlow.AddText($" the {lowerStar} - {upperStar} star difficulty filter."); - } - - // TODO: Add realm queries to hint at which ruleset results are available in (and allow clicking to switch). - // TODO: Make this message more certain by ensuring the osu! beatmaps exist before suggesting. - if (filter?.Ruleset?.OnlineID != 0 && filter?.AllowConvertedBeatmaps == false) - { - addBulletPoint(); - textFlow.AddText("Try "); - textFlow.AddLink("enabling", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - textFlow.AddText(" automatic conversion!"); - } - } - - if (!string.IsNullOrEmpty(filter?.SearchText)) - { - addBulletPoint(); - textFlow.AddText("Try "); - textFlow.AddLink("searching online", LinkAction.SearchBeatmapSet, filter.SearchText); - textFlow.AddText($" for \"{filter.SearchText}\"."); - } - // TODO: add clickable link to reset criteria. - } - - private void addBulletPoint() - { - textFlow.NewLine(); - textFlow.AddIcon(FontAwesome.Solid.Circle, i => - { - i.Padding = new MarginPadding { Top = 24, Right = 15 }; - i.Scale *= 0.3f; - }); - } - } -} diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs deleted file mode 100644 index e8843876d39a..000000000000 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ /dev/null @@ -1,1212 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Audio.Track; -using osu.Framework.Bindables; -using osu.Framework.Development; -using osu.Framework.Extensions; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Framework.Input.StateChanges; -using osu.Framework.Logging; -using osu.Framework.Screens; -using osu.Framework.Threading; -using osu.Game.Beatmaps; -using osu.Game.Collections; -using osu.Game.Configuration; -using osu.Game.Database; -using osu.Game.Graphics.Carousel; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Cursor; -using osu.Game.Graphics.UserInterface; -using osu.Game.Input.Bindings; -using osu.Game.Localisation; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Overlays.Volume; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Scoring; -using osu.Game.Screens.Footer; -using osu.Game.Screens.Menu; -using osu.Game.Screens.Play; -using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select; -using osu.Game.Skinning; -using osu.Game.Utils; -using osuTK; -using osuTK.Graphics; -using osuTK.Input; - -namespace osu.Game.Screens.SelectV2 -{ - /// - /// This screen is intended to house all components introduced in the new song select design to add transitions and examine the overall look. - /// This will be gradually built upon and ultimately replace once everything is in place. - /// - [Cached(typeof(ISongSelect))] - public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler, ISongSelect, IHandlePresentBeatmap - { - /// - /// A debounce that governs how long after a panel is selected before the rest of song select (and the game at large) - /// updates to show that selection. - /// - /// This is intentionally slightly higher than key repeat, but low enough to not impede user experience. - /// - public const int SELECTION_DEBOUNCE = 150; - - /// - /// A general "global" debounce to be applied to anything aggressive difficulty calculation at song select, - /// either after selection or after a panel comes on screen. Value should be low enough that users don't complain, - /// but otherwise as high as possible to reduce overheads. - /// - public const int DIFFICULTY_CALCULATION_DEBOUNCE = 150; - - private const float logo_scale = 0.4f; - private const double fade_duration = 300; - - public const float WEDGE_CONTENT_MARGIN = CORNER_RADIUS_HIDE_OFFSET + OsuGame.SCREEN_EDGE_MARGIN; - public const float CORNER_RADIUS_HIDE_OFFSET = 20f; - public const float ENTER_DURATION = 600; - - /// - /// Whether this song select instance should take control of the global track, - /// applying looping and preview offsets. - /// - protected bool ControlGlobalMusic { get; init; } = true; - - // Colour scheme for mod overlay is left as default (green) to match mods button. - // Not sure about this, but we'll iterate based on feedback. - private readonly ModSelectOverlay modSelectOverlay = new UserModSelectOverlay - { - ShowPresets = true, - }; - - private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!; - - // Blue is the most neutral choice, so I'm using that for now. - // Purple makes the most sense to match the "gameplay" flow, but it's a bit too strong for the current design. - // TODO: Colour scheme choice should probably be customisable by the user. - [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - - private BeatmapCarousel carousel = null!; - - private FilterControl filterControl = null!; - private BeatmapTitleWedge titleWedge = null!; - private BeatmapDetailsArea detailsArea = null!; - private FillFlowContainer wedgesContainer = null!; - private Box rightGradientBackground = null!; - private Container mainContent = null!; - private SkinnableContainer skinnableContent = null!; - - private NoResultsPlaceholder noResultsPlaceholder = null!; - - public override bool? ApplyModTrackAdjustments => true; - - public override bool ShowFooter => true; - - private Sample? errorSample; - - [Resolved] - private OsuGameBase? game { get; set; } - - [Resolved] - private OsuLogo? logo { get; set; } - - [Resolved] - private BeatmapSetOverlay? beatmapOverlay { get; set; } - - [Resolved] - private BeatmapManager beatmaps { get; set; } = null!; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - [Resolved] - private ManageCollectionsDialog? collectionsDialog { get; set; } - - [Resolved] - private DifficultyRecommender? difficultyRecommender { get; set; } - - [Resolved] - private IDialogOverlay? dialogOverlay { get; set; } - - private InputManager inputManager = null!; - - private readonly RealmPopulatingOnlineLookupSource onlineLookupSource = new RealmPopulatingOnlineLookupSource(); - - private Bindable configBackgroundBlur = null!; - private Bindable showConvertedBeatmaps = null!; - - [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuConfigManager config) - { - errorSample = audio.Samples.Get(@"UI/generic-error"); - - AddRangeInternal(new Drawable[] - { - new GlobalScrollAdjustsVolume(), - onlineLookupSource, - mainContent = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = ScreenFooter.HEIGHT }, - Child = new OsuContextMenuContainer - { - RelativeSizeAxes = Axes.Both, - Child = new PopoverContainer - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Width = 0.6f, - Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.3f), Color4.Black.Opacity(0f)), - }, - mainGridContainer = new GridContainer // used for max width implementation - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - // Ensure the left components are on top of the carousel both visually (although they should never overlay) - // but more importantly, for input purposes to allow the scroll-to-selection logic to override carousel's - // screen-wide scroll handling. - Depth = float.MinValue, - Shear = OsuGame.SHEAR, - Padding = new MarginPadding - { - Top = -CORNER_RADIUS_HIDE_OFFSET, - Left = -CORNER_RADIUS_HIDE_OFFSET, - }, - Children = new Drawable[] - { - new Container - { - // Pad enough to only reset scroll when well into the left wedge areas. - Padding = new MarginPadding { Right = 40 }, - RelativeSizeAxes = Axes.Both, - Child = new Select.SongSelect.LeftSideInteractionContainer(() => - { - carousel.ExpandGroupForCurrentSelection(); - carousel.ScrollToSelection(); - }) - { - RelativeSizeAxes = Axes.Both, - }, - }, - wedgesContainer = new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Spacing = new Vector2(0f, 4f), - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new ShearAligningWrapper(titleWedge = new BeatmapTitleWedge()), - new ShearAligningWrapper(detailsArea = new BeatmapDetailsArea()), - }, - }, - } - }, - Empty(), - new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - rightGradientBackground = new Box - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.0f), Color4.Black.Opacity(0.5f)), - RelativeSizeAxes = Axes.Both, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Top = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5, - Bottom = 5, - }, - Children = new Drawable[] - { - carousel = new BeatmapCarousel - { - BleedTop = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5, - BleedBottom = ScreenFooter.HEIGHT + 5, - RelativeSizeAxes = Axes.Both, - RequestPresentBeatmap = b => SelectAndRun(b, OnStart), - RequestSelection = queueBeatmapSelection, - RequestRecommendedSelection = requestRecommendedSelection, - NewItemsPresented = newItemsPresented, - }, - noResultsPlaceholder = new NoResultsPlaceholder - { - RequestClearFilterText = () => filterControl.Search(string.Empty) - } - } - }, - filterControl = new FilterControl - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.X, - }, - } - }, - }, - } - }, - } - }, - } - }, - skinnableContent = new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.SongSelect)) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - }, - modSpeedHotkeyHandler = new ModSpeedHotkeyHandler(), - modSelectOverlay, - }); - - configBackgroundBlur = config.GetBindable(OsuSetting.SongSelectBackgroundBlur); - configBackgroundBlur.BindValueChanged(e => - { - if (!this.IsCurrentScreen()) - return; - - updateBackgroundDim(); - }); - - showConvertedBeatmaps = config.GetBindable(OsuSetting.ShowConvertedBeatmaps); - } - - private void requestRecommendedSelection(IEnumerable groupedBeatmaps) - { - var recommendedBeatmap = difficultyRecommender?.GetRecommendedBeatmap(groupedBeatmaps.Select(gb => gb.Beatmap)) ?? groupedBeatmaps.First().Beatmap; - queueBeatmapSelection(groupedBeatmaps.First(bug => bug.Beatmap.Equals(recommendedBeatmap))); - } - - /// - /// Called when a selection is made to progress away from the song select screen. - /// - /// This is the default action which should be provided to . - /// - protected abstract void OnStart(); - - public override IReadOnlyList CreateFooterButtons() => new ScreenFooterButton[] - { - new FooterButtonMods(modSelectOverlay) - { - Hotkey = GlobalAction.ToggleModSelection, - Current = Mods, - RequestDeselectAllMods = () => - { - if (modSelectOverlay.State.Value == Visibility.Visible) - modSelectOverlay.DeselectAll(); - else - Mods.Value = Array.Empty(); - } - }, - new FooterButtonRandom - { - NextRandom = () => - { - if (!carousel.NextRandom()) - errorSample?.Play(); - }, - PreviousRandom = () => - { - if (!carousel.PreviousRandom()) - errorSample?.Play(); - } - }, - new FooterButtonOptions - { - Hotkey = GlobalAction.ToggleBeatmapOptions, - } - }; - - protected override void LoadComplete() - { - base.LoadComplete(); - - inputManager = GetContainingInputManager()!; - - filterControl.CriteriaChanged += criteriaChanged; - - modSelectOverlay.State.BindValueChanged(v => - { - if (!this.IsCurrentScreen()) - return; - - logo?.FadeTo(v.NewValue == Visibility.Visible ? 0f : 1f, 200, Easing.OutQuint); - }); - - Beatmap.BindValueChanged(_ => - { - if (!this.IsCurrentScreen()) - return; - - ensureGlobalBeatmapValid(); - - ensurePlayingSelected(); - updateBackgroundDim(); - updateWedgeVisibility(); - fetchOnlineInfo(); - }); - } - - protected override void Update() - { - base.Update(); - - detailsArea.Height = wedgesContainer.DrawHeight - titleWedge.LayoutSize.Y - 4; - - float widescreenBonusWidth = Math.Max(0, DrawWidth / DrawHeight - 2f); - - mainGridContainer.ColumnDimensions = new[] - { - new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 700 + widescreenBonusWidth * 100), - new Dimension(), - new Dimension(GridSizeMode.Relative, 0.5f, minSize: 500, maxSize: 700 + widescreenBonusWidth * 300), - }; - - if (this.IsCurrentScreen()) - updateDebounce(); - } - - #region Selection debounce - - private BeatmapInfo? debounceQueuedSelection; - private double debounceElapsedTime; - - private void debounceQueueSelection(BeatmapInfo beatmap) - { - debounceQueuedSelection = beatmap; - debounceElapsedTime = 0; - } - - private void updateDebounce() - { - if (debounceQueuedSelection == null) return; - - double elapsed = Clock.ElapsedFrameTime; - - // When a key is being held, assume the user is traversing the carousel using key repeat. - // We want to change panels less often in this state (basically making debounce longer than initial key repeat, at least). - double debounceInterval = inputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed ? SELECTION_DEBOUNCE * 2 : SELECTION_DEBOUNCE; - - // avoid debounce running early if there's a single long frame. - if (!DebugUtils.IsNUnitRunning && Clock.FramesPerSecond > 0) - elapsed = Math.Min(1000 / Clock.FramesPerSecond, elapsed); - - debounceElapsedTime += elapsed; - - if (debounceElapsedTime >= debounceInterval) - performDebounceSelection(); - } - - private void performDebounceSelection() - { - if (debounceQueuedSelection == null) return; - - try - { - if (Beatmap.Value.BeatmapInfo.Equals(debounceQueuedSelection)) - return; - - Beatmap.Value = beatmaps.GetWorkingBeatmap(debounceQueuedSelection); - } - finally - { - cancelDebounceSelection(); - } - } - - private void cancelDebounceSelection() - { - debounceQueuedSelection = null; - debounceElapsedTime = 0; - } - - #endregion - - #region Audio - - [Resolved] - private MusicController music { get; set; } = null!; - - private readonly WeakReference lastTrack = new WeakReference(null); - - /// - /// Ensures some music is playing for the current track. - /// Will resume playback from a manual user pause if the track has changed. - /// - private void ensurePlayingSelected() - { - if (!ControlGlobalMusic) - return; - - ITrack track = music.CurrentTrack; - - bool isNewTrack = !lastTrack.TryGetTarget(out var last) || last != track; - - if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) - { - Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}"); - - // Only restart playback if a new track. - // This is important so that when exiting gameplay, the track is not restarted back to the preview point. - music.Play(isNewTrack); - } - - lastTrack.SetTarget(track); - } - - private bool isHandlingLooping; - - private void beginLooping() - { - Debug.Assert(!isHandlingLooping); - - isHandlingLooping = true; - - ensureTrackLooping(Beatmap.Value, TrackChangeDirection.None); - - music.TrackChanged += ensureTrackLooping; - } - - private void endLooping() - { - // may be called multiple times during screen exit process. - if (!isHandlingLooping) - return; - - music.CurrentTrack.Looping = isHandlingLooping = false; - - music.TrackChanged -= ensureTrackLooping; - } - - private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection) - => beatmap.PrepareTrackForPreview(true); - - #endregion - - #region Selection handling - - /// - /// Finalises selection on the given and runs the provided action if possible. - /// - /// The beatmap which should be selected. If not provided, the current globally selected beatmap will be used. - /// The action to perform if conditions are met to be able to proceed. May not be invoked if in an invalid state. - public void SelectAndRun(BeatmapInfo beatmap, Action startAction) - { - if (!this.IsCurrentScreen()) - return; - - if (!checkBeatmapValidForSelection(beatmap)) - return; - - // To ensure sanity, cancel any pending selection as we are about to force a selection. - // Carousel selection will update to the forced selection via a call of `ensureGlobalBeatmapValid` below, or when song select becomes current again. - cancelDebounceSelection(); - - // Forced refetch is important here to guarantee correct invalidation across all difficulties (editor specific). - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap, true); - - if (Beatmap.IsDefault) - return; - - startAction(); - } - - /// - /// Prepares the proposed beatmap for global selection based on a carousel user-performed action. - /// - /// - /// Calling this method will: - /// - Immediately update the selection the carousel. - /// - After , update the global beatmap. This in turn causes song select visuals (title, details, leaderboard) to update. - /// This debounce is intended to avoid high overheads from churning lookups while a user is changing selection via rapid keyboard operations. - /// - /// The beatmap to be selected. - private void queueBeatmapSelection(GroupedBeatmap groupedBeatmap) - { - if (!this.IsCurrentScreen()) - return; - - carousel.CurrentGroupedBeatmap = groupedBeatmap; - - // Debounce consideration is to avoid beatmap churn on key repeat selection. - debounceQueueSelection(groupedBeatmap.Beatmap); - } - - private bool ensureGlobalBeatmapValid() - { - if (!this.IsCurrentScreen()) - return false; - - performDebounceSelection(); - - // While filtering, let's not ever attempt to change selection. - // This will be resolved after the filter completes, see `newItemsPresented`. - if (IsFiltering) - return false; - - // Refetch to be confident that the current selection is still valid. It may have been deleted or hidden. - var currentBeatmap = beatmaps.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo, true); - bool validSelection = checkBeatmapValidForSelection(currentBeatmap.BeatmapInfo); - - if (validSelection) - { - carousel.CurrentBeatmap = currentBeatmap.BeatmapInfo; - return true; - } - - // If there was no beatmap selected, pick a random one. - if (Beatmap.IsDefault) - { - validSelection = carousel.NextRandom(); - performDebounceSelection(); - return validSelection; - } - - // If a previous non-default selection became non-valid, it was likely hidden or deleted. - if (!validSelection) - { - // In the case a difficulty was hidden or removed, prefer selecting another difficulty from the same set. - var activeSet = currentBeatmap.BeatmapSetInfo; - - var validBeatmaps = activeSet.Beatmaps.Where(checkBeatmapValidForSelection).ToArray(); - - if (validBeatmaps.Any()) - { - var beatmap = difficultyRecommender?.GetRecommendedBeatmap(validBeatmaps) ?? validBeatmaps.First(); - carousel.CurrentBeatmap = beatmap; - debounceQueueSelection(beatmap); - return true; - } - } - - // If all else fails, use the default beatmap. - Beatmap.SetDefault(); - performDebounceSelection(); - - return validSelection; - } - - private bool checkBeatmapValidForSelection(BeatmapInfo beatmap) - { - if (!beatmap.AllowGameplayWithRuleset(Ruleset.Value, showConvertedBeatmaps.Value)) - return false; - - if (beatmap.Hidden) - return false; - - if (beatmap.BeatmapSet == null) - return false; - - if (beatmap.BeatmapSet.Protected || beatmap.BeatmapSet.DeletePending) - return false; - - return true; - } - - #endregion - - #region Transitions - - public override void OnEntering(ScreenTransitionEvent e) - { - base.OnEntering(e); - - this.FadeIn(); - onArrivingAtScreen(); - } - - public override void OnResuming(ScreenTransitionEvent e) - { - base.OnResuming(e); - - this.FadeIn(fade_duration, Easing.OutQuint); - onArrivingAtScreen(); - - ensureGlobalBeatmapValid(); - - detailsArea.Refresh(); - - if (ControlGlobalMusic) - { - // restart playback on returning to song select, regardless. - // not sure this should be a permanent thing (we may want to leave a user pause paused even on returning) - music.ResetTrackAdjustments(); - music.Play(requestedByUser: true); - } - } - - public override void OnSuspending(ScreenTransitionEvent e) - { - carousel.VisuallyFocusSelected = true; - - this.FadeOut(fade_duration, Easing.OutQuint); - onLeavingScreen(); - - base.OnSuspending(e); - } - - public override bool OnExiting(ScreenExitEvent e) - { - this.FadeOut(fade_duration, Easing.OutQuint); - onLeavingScreen(); - - return base.OnExiting(e); - } - - private void onArrivingAtScreen() - { - modSelectOverlay.Beatmap.BindTo(Beatmap); - // required due to https://github.com/ppy/osu-framework/issues/3218 - modSelectOverlay.SelectedMods.Disabled = false; - modSelectOverlay.SelectedMods.BindTo(Mods); - - carousel.VisuallyFocusSelected = false; - - updateWedgeVisibility(); - - if (ControlGlobalMusic) - { - // Avoid abruptly starting playback at preview point. - // Importantly, this should be done before looping is setup to ensure we get the correct imminent `IsPlaying` state. - if (!music.IsPlaying) - { - music.DuckMomentarily(0, new DuckParameters - { - DuckDuration = 0, - DuckVolumeTo = 0, - RestoreDuration = 800, - RestoreEasing = Easing.OutQuint - }); - } - - beginLooping(); - } - - ensureGlobalBeatmapValid(); - - ensurePlayingSelected(); - updateBackgroundDim(); - fetchOnlineInfo(force: true); - } - - private void onLeavingScreen() - { - restoreBackground(); - - modSelectOverlay.SelectedMods.UnbindFrom(Mods); - modSelectOverlay.Beatmap.UnbindFrom(Beatmap); - - updateWedgeVisibility(); - - endLooping(); - } - - protected override void LogoArriving(OsuLogo logo, bool resuming) - { - base.LogoArriving(logo, resuming); - - if (logo.Alpha > 0.8f && resuming) - Footer?.StartTrackingLogo(logo, 400, Easing.OutQuint); - else - { - logo.Hide(); - logo.ScaleTo(0.2f); - Footer?.StartTrackingLogo(logo); - } - - logo.FadeIn(240, Easing.OutQuint); - logo.ScaleTo(logo_scale, 240, Easing.OutQuint); - - logo.Action = () => - { - SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart); - return false; - }; - } - - protected override void LogoSuspending(OsuLogo logo) - { - base.LogoSuspending(logo); - Footer?.StopTrackingLogo(); - } - - protected override void LogoExiting(OsuLogo logo) - { - base.LogoExiting(logo); - - Footer?.StopTrackingLogo(); - - logo.ScaleTo(0.2f, 120, Easing.Out); - logo.FadeOut(120, Easing.Out); - } - - private void updateWedgeVisibility() - { - // Ensure we don't show an invalid selection before the carousel has finished initially filtering. - // This avoids a flicker of a placeholder or invalid beatmap before a proper selection. - // - // After the carousel finishes filtering, it will attempt a selection then call this method again. - if (!CarouselItemsPresented && !checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo)) - return; - - if (carousel.VisuallyFocusSelected) - { - titleWedge.Hide(); - detailsArea.Hide(); - filterControl.Hide(); - } - else - { - titleWedge.Show(); - detailsArea.Show(); - filterControl.Show(); - } - } - - private void updateBackgroundDim() => ApplyToBackground(backgroundModeBeatmap => - { - backgroundModeBeatmap.Beatmap = Beatmap.Value; - backgroundModeBeatmap.IgnoreUserSettings.Value = true; - - backgroundModeBeatmap.DimWhenUserSettingsIgnored.Value = 0.1f; - - // Required to undo results screen dimming the background. - // Probably needs more thought because this needs to be in every `ApplyToBackground` currently to restore sane defaults. - backgroundModeBeatmap.FadeColour(Color4.White, 250); - - backgroundModeBeatmap.BlurAmount.Value = revealingBackground == null && configBackgroundBlur.Value ? 20 : 0f; - }); - - #endregion - - #region Filtering - - /// - /// Whether the carousel has finished initial presentation of beatmap panels. - /// - public bool CarouselItemsPresented { get; private set; } - - /// - /// Whether the carousel is or will be undergoing a filter operation. - /// - public bool IsFiltering => carousel.IsFiltering || filterDebounce?.State == ScheduledDelegate.RunState.Waiting; - - private const double filter_delay = 250; - - private ScheduledDelegate? filterDebounce; - - private void criteriaChanged(FilterCriteria criteria) - { - filterDebounce?.Cancel(); - - // The first filter needs to be applied immediately as this triggers the initial carousel load. - bool isFirstFilter = filterDebounce == null; - - // Criteria change may have included a ruleset change which made the current selection invalid. - bool isSelectionValid = checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo); - - filterDebounce = Scheduler.AddDelayed(() => carousel.Filter(criteria, !isSelectionValid), isFirstFilter || !isSelectionValid ? 0 : filter_delay); - } - - private void newItemsPresented(IEnumerable carouselItems) - { - if (carousel.Criteria == null) - return; - - CarouselItemsPresented = true; - - int count = carousel.MatchedBeatmapsCount; - - updateNoResultsPlaceholder(); - - // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 - // but also in this case we want support for formatting a number within a string). - filterControl.StatusText = count != 1 ? $"{count:#,0} matches" : $"{count:#,0} match"; - - // If there's already a selection update in progress, let's not interrupt it. - // Interrupting could cause the debounce interval to be reduced. - // - // `ensureGlobalBeatmapValid` is run post-selection which will resolve any pending incompatibilities (see `Beatmap` bindable callback). - if (debounceQueuedSelection == null) - ensureGlobalBeatmapValid(); - - updateWedgeVisibility(); - } - - private void updateNoResultsPlaceholder() - { - int count = carousel.MatchedBeatmapsCount; - - if (count == 0) - { - if (noResultsPlaceholder.State.Value == Visibility.Hidden) - { - // Duck audio temporarily when the no results placeholder becomes visible. - // - // Temporary ducking makes it easier to avoid scenarios where the ducking interacts badly - // with other global UI components (like overlays). - music.DuckMomentarily(400, new DuckParameters - { - DuckVolumeTo = 1, - DuckCutoffTo = 500, - DuckDuration = 250, - RestoreDuration = 2000, - }); - } - - noResultsPlaceholder.Show(); - noResultsPlaceholder.Filter = carousel.Criteria!; - - rightGradientBackground.ResizeWidthTo(3, 1000, Easing.OutPow10); - } - else - { - noResultsPlaceholder.Hide(); - - rightGradientBackground.ResizeWidthTo(1, 400, Easing.OutPow10); - } - } - - #endregion - - #region Input - - private ScheduledDelegate? revealingBackground; - - private GridContainer mainGridContainer = null!; - - protected override bool OnMouseDown(MouseDownEvent e) - { - var containingInputManager = GetContainingInputManager(); - - // I don't know why this works, but it does. - // If the carousel panels are hovered, hovered no longer contains the screen. - // Maybe there's a better way of doing this, but I couldn't immediately find a good setup. - bool mouseDownPriority = containingInputManager!.HoveredDrawables.Contains(this); - - // Touch input synthesises right clicks, which allow absolute scroll of the carousel. - // For simplicity, disable this functionality on mobile. - bool isTouchInput = e.CurrentState.Mouse.LastSource is ISourcedFromTouch; - - if (!carousel.AbsoluteScrolling && !isTouchInput && mouseDownPriority && revealingBackground == null) - { - revealingBackground = Scheduler.AddDelayed(() => - { - if (containingInputManager.DraggedDrawable != null) - { - revealingBackground = null; - return; - } - - mainContent.ResizeWidthTo(1.2f, 600, Easing.OutQuint); - mainContent.ScaleTo(1.2f, 600, Easing.OutQuint); - mainContent.FadeOut(200, Easing.OutQuint); - - skinnableContent.ResizeWidthTo(1.2f, 600, Easing.OutQuint); - skinnableContent.ScaleTo(1.2f, 600, Easing.OutQuint); - skinnableContent.FadeOut(200, Easing.OutQuint); - - updateBackgroundDim(); - - Footer?.Hide(); - }, 200); - } - - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - restoreBackground(); - base.OnMouseUp(e); - } - - private void restoreBackground() - { - if (revealingBackground == null) - return; - - if (revealingBackground.State == ScheduledDelegate.RunState.Complete) - { - mainContent.ResizeWidthTo(1f, 500, Easing.OutQuint); - mainContent.ScaleTo(1, 500, Easing.OutQuint); - mainContent.FadeIn(500, Easing.OutQuint); - - skinnableContent.ResizeWidthTo(1f, 500, Easing.OutQuint); - skinnableContent.ScaleTo(1, 500, Easing.OutQuint); - skinnableContent.FadeIn(500, Easing.OutQuint); - - Footer?.Show(); - } - - revealingBackground.Cancel(); - revealingBackground = null; - - updateBackgroundDim(); - } - - public virtual bool OnPressed(KeyBindingPressEvent e) - { - if (!this.IsCurrentScreen()) return false; - - if (game == null) - return false; - - var flattenedMods = ModUtils.FlattenMods(game.AvailableMods.Value.SelectMany(kv => kv.Value)); - - switch (e.Action) - { - case GlobalAction.Select: - // in most circumstances this is handled already by the carousel itself, but there are cases where it will not be. - // one of which is filtering out all visible beatmaps and attempting to start gameplay. - // in that case, users still expect a `Select` press to advance to gameplay anyway, using the ambient selected beatmap if there is one, - // which matches the behaviour resulting from clicking the osu! cookie in that scenario. - SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart); - return true; - - case GlobalAction.IncreaseModSpeed: - return modSpeedHotkeyHandler.ChangeSpeed(0.05, flattenedMods); - - case GlobalAction.DecreaseModSpeed: - return modSpeedHotkeyHandler.ChangeSpeed(-0.05, flattenedMods); - } - - return false; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - } - - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.Repeat) return false; - - switch (e.Key) - { - case Key.Delete: - if (e.ShiftPressed) - { - if (!Beatmap.IsDefault) - Delete(Beatmap.Value.BeatmapSetInfo); - return true; - } - - break; - } - - return base.OnKeyDown(e); - } - - #endregion - - #region Online lookups - - public enum BeatmapSetLookupStatus - { - InProgress, - Completed, - } - - public class BeatmapSetLookupResult - { - public BeatmapSetLookupStatus Status { get; } - public APIBeatmapSet? Result { get; } - - private BeatmapSetLookupResult(BeatmapSetLookupStatus status, APIBeatmapSet? result) - { - Status = status; - Result = result; - } - - public static BeatmapSetLookupResult InProgress() => new BeatmapSetLookupResult(BeatmapSetLookupStatus.InProgress, null); - public static BeatmapSetLookupResult Completed(APIBeatmapSet? beatmapSet) => new BeatmapSetLookupResult(BeatmapSetLookupStatus.Completed, beatmapSet); - } - - /// - /// Result of the latest online beatmap set lookup. - /// Note that this being or is different from - /// being a with a of null. - /// The former indicates a lookup never occurring or being in progress, while the latter indicates a completed lookup with no result. - /// - [Cached(typeof(IBindable))] - private readonly Bindable lastLookupResult = new Bindable(); - - private CancellationTokenSource? onlineLookupCancellation; - private Task? currentOnlineLookup; - - private void fetchOnlineInfo(bool force = false) - { - var beatmapSetInfo = Beatmap.Value.BeatmapSetInfo; - - if (lastLookupResult.Value?.Result?.OnlineID == beatmapSetInfo.OnlineID && !force) - return; - - onlineLookupCancellation?.Cancel(); - onlineLookupCancellation = null; - - if (beatmapSetInfo.OnlineID < 0) - { - lastLookupResult.Value = BeatmapSetLookupResult.Completed(null); - return; - } - - lastLookupResult.Value = BeatmapSetLookupResult.InProgress(); - onlineLookupCancellation = new CancellationTokenSource(); - currentOnlineLookup = onlineLookupSource.GetBeatmapSetAsync(beatmapSetInfo.OnlineID, onlineLookupCancellation.Token); - currentOnlineLookup.ContinueWith(t => - { - if (t.IsCompletedSuccessfully) - Schedule(() => lastLookupResult.Value = BeatmapSetLookupResult.Completed(t.GetResultSafely())); - - if (t.Exception != null) - { - Logger.Log($"Error when fetching online beatmap set: {t.Exception}", LoggingTarget.Network); - Schedule(() => lastLookupResult.Value = BeatmapSetLookupResult.Completed(null)); - } - }); - } - - #endregion - - #region Implementation of ISongSelect - - void ISongSelect.Search(string query) => filterControl.Search(query); - - void ISongSelect.PresentScore(ScoreInfo score) - { - Debug.Assert(Beatmap.Value.BeatmapInfo.Equals(score.BeatmapInfo)); - Debug.Assert(Ruleset.Value.Equals(score.Ruleset)); - - this.Push(new SoloResultsScreen(score)); - } - - #endregion - - #region IHandlePresentBeatmap - - void IHandlePresentBeatmap.PresentBeatmap(WorkingBeatmap workingBeatmap, RulesetInfo ruleset) - { - cancelDebounceSelection(); - - var beatmapInfo = workingBeatmap.BeatmapInfo; - - // Don't change the local ruleset if the user is on another ruleset and is showing converted beatmaps. - // Eventually we probably want to check whether conversion is actually possible for the current ruleset. - bool requiresRulesetSwitch = !beatmapInfo.Ruleset.Equals(Ruleset.Value) - && (beatmapInfo.Ruleset.OnlineID > 0 || !showConvertedBeatmaps.Value); - - if (requiresRulesetSwitch) - { - Ruleset.Value = beatmapInfo.Ruleset; - Beatmap.Value = workingBeatmap; - - Logger.Log($"Completing {nameof(IHandlePresentBeatmap.PresentBeatmap)} with beatmap {workingBeatmap} ruleset {beatmapInfo.Ruleset}"); - } - else - { - Beatmap.Value = workingBeatmap; - - Logger.Log($"Completing {nameof(IHandlePresentBeatmap.PresentBeatmap)} with beatmap {workingBeatmap} (maintaining ruleset)"); - } - } - - #endregion - - #region Beatmap management - - [Resolved] - private ManageCollectionsDialog? manageCollectionsDialog { get; set; } - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - public virtual IEnumerable GetForwardActions(BeatmapInfo beatmap) - { - yield return new OsuMenuItem(GlobalActionKeyBindingStrings.Select, MenuItemType.Highlighted, () => SelectAndRun(beatmap, OnStart)) - { - Icon = FontAwesome.Solid.Check - }; - - yield return new OsuMenuItemSpacer(); - - if (beatmap.OnlineID > 0) - { - yield return new OsuMenuItem(CommonStrings.Details, MenuItemType.Standard, () => beatmapOverlay?.FetchAndShowBeatmap(beatmap.OnlineID)); - - if (beatmap.GetOnlineURL(api, Ruleset.Value) is string url) - yield return new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => (game as OsuGame)?.CopyToClipboard(url)); - } - - yield return new OsuMenuItemSpacer(); - - foreach (var i in CreateCollectionMenuActions(beatmap)) - yield return i; - } - - protected IEnumerable CreateCollectionMenuActions(BeatmapInfo beatmap) - { - var collectionItems = realm.Realm.All() - .OrderBy(c => c.Name) - .AsEnumerable() - .Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmap)).Cast().ToList(); - - collectionItems.Add(new OsuMenuItem(CommonStrings.Manage, MenuItemType.Standard, () => manageCollectionsDialog?.Show())); - - yield return new OsuMenuItem(CommonStrings.Collections) { Items = collectionItems }; - } - - public void ManageCollections() => collectionsDialog?.Show(); - - public void Delete(BeatmapSetInfo beatmapSet) => dialogOverlay?.Push(new BeatmapDeleteDialog(beatmapSet)); - - public void RestoreAllHidden(BeatmapSetInfo beatmapSet) - { - foreach (var b in beatmapSet.Beatmaps) - beatmaps.Restore(b); - } - - #endregion - } -} diff --git a/osu.Game/Screens/SelectV2/WedgeBackground.cs b/osu.Game/Screens/SelectV2/WedgeBackground.cs deleted file mode 100644 index 3fa21beee237..000000000000 --- a/osu.Game/Screens/SelectV2/WedgeBackground.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Overlays; - -namespace osu.Game.Screens.SelectV2 -{ - internal sealed partial class WedgeBackground : InputBlockingContainer - { - public float StartAlpha { get; init; } = 0.9f; - - public float FinalAlpha { get; init; } = 0.6f; - - public float WidthForGradient { get; init; } = 0.3f; - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new Box - { - Blending = BlendingParameters.Additive, - RelativeSizeAxes = Axes.Both, - Width = 0.6f, - Alpha = 0.5f, - Colour = ColourInfo.GradientHorizontal(colourProvider.Background2, colourProvider.Background2.Opacity(0)), - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Width = 1 - WidthForGradient, - Colour = colourProvider.Background5.Opacity(StartAlpha), - }, - new Box - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Both, - Width = WidthForGradient, - Colour = ColourInfo.GradientHorizontal(colourProvider.Background5.Opacity(StartAlpha), colourProvider.Background5.Opacity(FinalAlpha)), - }, - }; - } - } -} diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs index 9e8fe4f617b4..6d922e14028d 100644 --- a/osu.Game/Skinning/ArgonSkin.cs +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -128,6 +128,9 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources) if (spectatorList != null) spectatorList.Position = pos; + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { RelativeSizeAxes = Axes.Both, @@ -238,6 +241,9 @@ public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources) keyCounter.Position = new Vector2(-(hitError.Width + padding), -(padding * 2 + song_progress_offset_height)); } } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; } }) { diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index e198d43be740..365652e6ad25 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -26,6 +26,8 @@ public class LegacyBeatmapSkin : LegacySkin // 2. https://github.com/peppy/osu-stable-reference/blob/dc0994645801010d4b628fff5ff79cd3c286ca83/osu!/Graphics/Textures/TextureManager.cs#L158-L196 (user skin textures lookup) protected override bool AllowHighResolutionSprites => false; + public RealmBackedResourceStore? BeatmapSetResources => FallbackStore as RealmBackedResourceStore; + /// /// Construct a new legacy beatmap skin instance. /// diff --git a/osu.Game/Skinning/LegacyFont.cs b/osu.Game/Skinning/LegacyFont.cs index d1971cb84ce6..bd4971faa4de 100644 --- a/osu.Game/Skinning/LegacyFont.cs +++ b/osu.Game/Skinning/LegacyFont.cs @@ -11,5 +11,6 @@ public enum LegacyFont Score, Combo, HitCircle, + ScoreEntry, } } diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 0d561d6c8901..bd59eb342df4 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -233,6 +233,7 @@ private void load() public override void Flash(bool isEpic) { Bulge(); + explode.Texture = Main.Texture; explode.Blending = isEpic ? BlendingParameters.Additive : BlendingParameters.Inherit; explode.ScaleTo(1).Then().ScaleTo(isEpic ? 2 : 1.6f, 120, Easing.Out); explode.FadeOutFromOne(120, Easing.Out); diff --git a/osu.Game/Skinning/LegacyKeyCounter.cs b/osu.Game/Skinning/LegacyKeyCounter.cs index 609e21b9ffb3..1879c999641a 100644 --- a/osu.Game/Skinning/LegacyKeyCounter.cs +++ b/osu.Game/Skinning/LegacyKeyCounter.cs @@ -27,14 +27,19 @@ public Colour4 TextColour set { textColour = value; + initialNameText.Colour = value; overlayKeyText.Colour = value; } } private readonly Container keyContainer; - private readonly OsuSpriteText overlayKeyText; + private readonly LegacySpriteText overlayKeyText; + private readonly OsuSpriteText initialNameText; + private readonly Sprite keySprite; + private bool activatedOnce; + public LegacyKeyCounter(InputTrigger trigger) : base(trigger) { @@ -57,14 +62,26 @@ public LegacyKeyCounter(InputTrigger trigger) AutoSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = overlayKeyText = new OsuSpriteText + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = trigger.Name, - Colour = textColour, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold), - }, + // The legacy font doesn't contain all the characters necessary to display placeholders. + // Keep things simple by using a normal font for this case. + initialNameText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = trigger.Name, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold), + Colour = textColour, + }, + overlayKeyText = new LegacySpriteText(LegacyFont.ScoreEntry) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Colour = textColour, + } + } }, } }; @@ -85,10 +102,18 @@ private void load(ISkinSource source) protected override void Activate(bool forwardPlayback = true) { base.Activate(forwardPlayback); + keyContainer.ScaleTo(0.75f, transition_duration, Easing.Out); keySprite.Colour = ActiveColour; + overlayKeyText.Text = CountPresses.Value.ToString(); - overlayKeyText.Font = overlayKeyText.Font.With(weight: FontWeight.SemiBold); + + if (forwardPlayback && !activatedOnce) + { + activatedOnce = true; + initialNameText.FadeOut(transition_duration, Easing.Out); + overlayKeyText.FadeIn(transition_duration, Easing.Out); + } } protected override void Deactivate(bool forwardPlayback = true) @@ -96,6 +121,13 @@ protected override void Deactivate(bool forwardPlayback = true) base.Deactivate(forwardPlayback); keyContainer.ScaleTo(1f, transition_duration, Easing.Out); keySprite.Colour = Colour4.White; + + if (!forwardPlayback && activatedOnce && CountPresses.Value == 0) + { + activatedOnce = false; + initialNameText.FadeIn(transition_duration, Easing.Out); + overlayKeyText.FadeOut(transition_duration, Easing.Out); + } } } } diff --git a/osu.Game/Skinning/LegacyPerformancePointsCounter.cs b/osu.Game/Skinning/LegacyPerformancePointsCounter.cs new file mode 100644 index 000000000000..a71ecaabde70 --- /dev/null +++ b/osu.Game/Skinning/LegacyPerformancePointsCounter.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Skinning +{ + public partial class LegacyPerformancePointsCounter : PerformancePointsCounter, ISerialisableDrawable + { + protected override double RollingDuration => 1000; + protected override Easing RollingEasing => Easing.Out; + + private const float alpha_when_invalid = 0.3f; + + public LegacyPerformancePointsCounter() + { + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + + Scale = new Vector2(0.96f); + } + + public override bool IsValid + { + get => base.IsValid; + set + { + if (value == IsValid) + return; + + base.IsValid = value; + DrawableCount.FadeTo(value ? 1 : alpha_when_invalid, 1000, Easing.OutQuint); + } + } + + protected override LocalisableString FormatCount(int count) => count.ToString($@"0'{LegacySpriteText.PP_SUFFIX_CHAR}'"); + + protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(LegacyFont.Score); + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 11b3b5c71d1c..74b404a578bf 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -412,6 +412,9 @@ protected override void ParseConfigurationStream(Stream stream) leaderboard.Origin = Anchor.CentreLeft; leaderboard.X = 10; } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { new LegacyDefaultComboCounter(), @@ -448,6 +451,9 @@ protected override void ParseConfigurationStream(Stream stream) hitError.Origin = Anchor.CentreLeft; hitError.Rotation = -90; } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { Children = new Drawable[] @@ -540,6 +546,10 @@ protected override void ParseConfigurationStream(Stream stream) case "Menu/fountain-star": componentName = "star2"; break; + + case @"Intro/Welcome/welcome_text": + componentName = @"welcome_text"; + break; } Texture? texture = null; diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index a8ec67d98b57..0339194c9e4e 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -55,7 +55,8 @@ public static partial class LegacySkinExtensions } } - public static Texture[] GetTextures(this ISkin? source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, string animationSeparator, Vector2? maxSize, out ISkin? retrievalSource) + public static Texture[] GetTextures(this ISkin? source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, string animationSeparator, Vector2? maxSize, + out ISkin? retrievalSource) { retrievalSource = null; @@ -140,6 +141,9 @@ public static string GetFontPrefix(this ISkin source, LegacyFont font) { switch (font) { + case LegacyFont.ScoreEntry: + return "scoreentry"; + case LegacyFont.Score: return source.GetConfig(LegacySetting.ScorePrefix)?.Value ?? "score"; @@ -163,6 +167,9 @@ public static float GetFontOverlap(this ISkin source, LegacyFont font) { switch (font) { + case LegacyFont.ScoreEntry: + return 1; + case LegacyFont.Score: return source.GetConfig(LegacySetting.ScoreOverlap)?.Value ?? 0f; diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index 1028b5bb9d11..e307577c2f10 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -14,6 +14,11 @@ namespace osu.Game.Skinning { public sealed partial class LegacySpriteText : OsuSpriteText { + /// + /// The Private Use Area character for internally representing the "pp" suffix for performance counters. + /// + public const char PP_SUFFIX_CHAR = '\uebd9'; + public Vector2? MaxSizePerGlyph { get; init; } public bool FixedWidth { get; init; } @@ -23,7 +28,7 @@ public sealed partial class LegacySpriteText : OsuSpriteText protected override char FixedWidthReferenceCharacter => '5'; - protected override char[] FixedWidthExcludeCharacters => new[] { ',', '.', '%', 'x' }; + protected override char[] FixedWidthExcludeCharacters => new[] { ',', '.', '%', 'x', PP_SUFFIX_CHAR }; // ReSharper disable once UnusedMember.Global // being unused is the point here @@ -116,6 +121,9 @@ private static string getLookupName(char character) case '%': return "percent"; + case PP_SUFFIX_CHAR: + return "pp"; + default: return character.ToString(); } diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs index 093248534918..36561e0d696f 100644 --- a/osu.Game/Skinning/RealmBackedResourceStore.cs +++ b/osu.Game/Skinning/RealmBackedResourceStore.cs @@ -16,6 +16,8 @@ namespace osu.Game.Skinning public class RealmBackedResourceStore : ResourceStore where T : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey { + public event Action? CacheInvalidated; + private Lazy> fileToStoragePathMapping; private readonly Live liveSource; @@ -56,7 +58,11 @@ protected override IEnumerable GetFilenames(string name) private string? getPathForFile(string filename) => fileToStoragePathMapping.Value.GetValueOrDefault(filename.ToLowerInvariant()); - private void invalidateCache() => fileToStoragePathMapping = new Lazy>(initialiseFileCache); + private void invalidateCache() + { + fileToStoragePathMapping = new Lazy>(initialiseFileCache); + CacheInvalidated?.Invoke(); + } private Dictionary initialiseFileCache() => liveSource.PerformRead(source => { diff --git a/osu.Game/Skinning/RetroSkin.cs b/osu.Game/Skinning/RetroSkin.cs index abeab9ab17a6..20214dfb6720 100644 --- a/osu.Game/Skinning/RetroSkin.cs +++ b/osu.Game/Skinning/RetroSkin.cs @@ -7,6 +7,7 @@ using osu.Framework.IO.Stores; using osu.Game.Extensions; using osu.Game.IO; +using osuTK.Graphics; namespace osu.Game.Skinning { @@ -40,6 +41,23 @@ public RetroSkin(SkinInfo skin, IStorageResourceProvider resources) new NamespacedResourceStore(resources.Resources, "Skins/Retro") ) { + Configuration.ConfigDictionary[@"SliderBallFlip"] = "0"; + Configuration.ConfigDictionary[@"SliderBallFrames"] = "10"; + Configuration.ConfigDictionary[@"AllowSliderBallTint"] = "0"; + Configuration.ConfigDictionary[@"CursorTrailRotate"] = "0"; + Configuration.ConfigDictionary[@"Version"] = "1"; + + Configuration.CustomComboColours = + [ + new Color4(255, 150, 0, 255), + new Color4(5, 240, 5, 255), + new Color4(5, 5, 240, 255), + new Color4(240, 5, 5, 255) + ]; + + Configuration.ConfigDictionary[@"HitCircleOverlap"] = "3"; + Configuration.ConfigDictionary[@"ScoreOverlap"] = "3"; + Configuration.ConfigDictionary[@"ComboOverlap"] = "3"; } public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 07902106ef61..d50fc2e3d075 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -38,7 +38,7 @@ public abstract class Skin : IDisposable, ISkin /// /// A sample store which can be used to perform user file lookups for this skin. /// - protected ISampleStore? Samples { get; } + protected internal ISampleStore? Samples { get; private set; } public readonly Live SkinInfo; @@ -63,6 +63,8 @@ public abstract class Skin : IDisposable, ISkin public string Name { get; } + protected IResourceStore? FallbackStore { get; } + /// /// Construct a new skin. /// @@ -82,18 +84,7 @@ protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStor store.AddStore(new RealmBackedResourceStore(SkinInfo, resources.Files, resources.RealmAccess)); - var samples = resources.AudioManager?.GetSampleStore(store); - - if (samples != null) - { - samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; - - // osu-stable performs audio lookups in order of wav -> mp3 -> ogg. - // The GetSampleStore() call above internally adds wav and mp3, so ogg is added at the end to ensure expected ordering. - samples.AddExtension(@"ogg"); - } - - Samples = samples; + RecycleSamples(); Textures = new TextureStore(resources.Renderer, CreateTextureLoaderStore(resources, store)); } else @@ -102,6 +93,7 @@ protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStor SkinInfo = skin.ToLiveUnmanaged(); } + FallbackStore = fallbackStore; if (fallbackStore != null) store.AddStore(fallbackStore); @@ -150,6 +142,30 @@ protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStor } } + /// + /// Recreates . + /// All users of samples from the skin are expected to manually re-retrieve their samples from this skin after this is called. + /// Exposed as public for the purpose of e.g. editing flows where the skin's set of available samples changes. + /// In such a scenario a full recycle of the store is required to avoid accidentally retrieving stale samples that don't exist in the skin anymore. + /// + public void RecycleSamples() + { + Samples?.Dispose(); + + var samples = resources?.AudioManager?.GetSampleStore(store); + + if (samples != null) + { + samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + + // osu-stable performs audio lookups in order of wav -> mp3 -> ogg. + // The GetSampleStore() call above internally adds wav and mp3, so ogg is added at the end to ensure expected ordering. + samples.AddExtension(@"ogg"); + } + + Samples = samples; + } + protected virtual IResourceStore CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore storage) => new MaxDimensionLimitedTextureLoaderStore(resources.CreateTextureLoaderStore(storage)); @@ -228,8 +244,9 @@ public void UpdateDrawableTarget(SkinnableContainer targetContainer) // First attempt to deserialise using the new SkinLayoutInfo format layout = JsonConvert.DeserializeObject(jsonContent); } - catch + catch (Exception ex) { + Logger.Log($"Deserialising skin layout to {nameof(SkinLayoutInfo)} failed. Falling back to {nameof(SerialisedDrawableInfo)}[].\nDetails: {ex}"); } // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list. @@ -338,6 +355,7 @@ protected virtual void Dispose(bool isDisposing) Textures?.Dispose(); Samples?.Dispose(); + FallbackStore?.Dispose(); store.Dispose(); } diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 3a50fb9f9af7..6290e3439a2f 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -177,9 +177,10 @@ public void UpdateSkinIniMetadata(SkinInfo item, Realm realm) if (existingFile == null) { - // skins without a skin.ini are supposed to import using the "latest version" spec. + // skins without a skin.ini are supposed to import using the "latest version" spec, unless we're making a copy of the retro skin which specifies 1.0. // see https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/Graphics/Skinning/SkinManager.cs#L297-L298 - newLines.Add(FormattableString.Invariant($"Version: {SkinConfiguration.LATEST_VERSION}")); + decimal version = item.InstantiationInfo == typeof(RetroSkin).GetInvariantInstantiationInfo() ? 1.0M : SkinConfiguration.LATEST_VERSION; + newLines.Add(FormattableString.Invariant($"Version: {version}")); // In the case a skin doesn't have a skin.ini yet, let's create one. writeNewSkinIni(); diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index e92d0d3d49a9..e62b2a150c83 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Linq.Expressions; using System.Threading; @@ -66,6 +67,12 @@ public class SkinManager : ModelManager, ISkinSource, IStorageResource private Skin retroSkin { get; } + private static readonly Live random_skin_info = new SkinInfo + { + ID = SkinInfo.RANDOM_SKIN, + Name = "", + }.ToLiveUnmanaged(); + public override bool PauseImports { get => base.PauseImports; @@ -130,6 +137,38 @@ public SkinManager(Storage storage, RealmAccess realm, GameHost host, IResourceS }; } + /// + /// Returns the dropdown ordering for use mainly by the skin selection UI. + /// Inserts the defaults first, then 'random skin', then custom ones. + /// Returns a list of items. + /// + public IList> GetAllUsableSkins() + { + var skins = new List>(); + + Realm.Run(realm => + { + skins.Add(realm.Find(SkinInfo.ARGON_SKIN).ToLive(Realm)); + skins.Add(realm.Find(SkinInfo.ARGON_PRO_SKIN).ToLive(Realm)); + skins.Add(realm.Find(SkinInfo.TRIANGLES_SKIN).ToLive(Realm)); + skins.Add(realm.Find(SkinInfo.CLASSIC_SKIN).ToLive(Realm)); + skins.Add(realm.Find(SkinInfo.RETRO_SKIN).ToLive(Realm)); + + skins.Add(random_skin_info); + + var userSkins = realm.All() + .Where(s => !s.DeletePending && !s.Protected) + .AsEnumerable() + .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .Select(s => s.ToLive(Realm)); + + foreach (var s in userSkins) + skins.Add(s); + }); + + return skins; + } + public void SelectRandomSkin() { Realm.Run(r => @@ -158,6 +197,40 @@ public void SelectRandomSkin() }); } + private void cycleSkins(int direction) + { + Debug.Assert(direction != 0); + + // don't change selection if current skin is externally disabled/mounted for editing. + if (CurrentSkinInfo.Disabled) + return; + + var skins = GetAllUsableSkins(); + + int i = skins.IndexOf(CurrentSkinInfo.Value); + + // If the current skin isn't selectable anymore, start from the top. + if (i < 0 && direction < 0) + i = 0; + + do + { + i = (i + direction + skins.Count) % skins.Count; + } while (skins[i].ID == SkinInfo.RANDOM_SKIN); + + CurrentSkinInfo.Value = skins[i]; + } + + /// + /// Cycle one skin backward. + /// + public void SelectPreviousSkin() => cycleSkins(-1); + + /// + /// Cycle one skin forward. + /// + public void SelectNextSkin() => cycleSkins(1); + /// /// Retrieve a instance for the provided /// diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs index 3881a5e97081..ae3df35383b5 100644 --- a/osu.Game/Skinning/TrianglesSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -106,6 +106,9 @@ public TrianglesSkin(SkinInfo skin, IStorageResourceProvider resources) spectatorList.Origin = Anchor.BottomLeft; spectatorList.Position = new Vector2(screen_edge_padding, -(song_progress_offset_height + screen_edge_padding)); } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { RelativeSizeAxes = Axes.Both, @@ -178,6 +181,9 @@ public TrianglesSkin(SkinInfo skin, IStorageResourceProvider resources) keyCounter.Origin = Anchor.BottomRight; keyCounter.Position = new Vector2(-screen_edge_padding, -(song_progress_offset_height + screen_edge_padding)); } + + foreach (var d in container.OfType()) + d.UsesFixedAnchor = true; }) { Children = new Drawable[] diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index f66f84af7a58..4a61bf16fc31 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -89,6 +89,21 @@ public DrawableStoryboardAnimation(StoryboardAnimation animation) LifetimeEnd = animation.EndTimeForDisplay; } + protected override void Update() + { + base.Update(); + + // In stable, alpha transforms exceeding values of 1 would result in sprites disappearing from view. + // See https://github.com/peppy/osu-stable-reference/blob/08e3dafd525934cf48880b08e91c24ce4ad8b761/osu!/Graphics/Sprites/pSprite.cs#L413-L414 + // + // Over the years, storyboard(ers) have taken advantage of this to create "flicker" patterns. + // This is quite a common technique, so we are reproducing it here for now. + // + // NOTE TO FUTURE VISTIORS: If we do ever update the storyboard spec, we may want to move such flicker effects to their + // own transform type, and make this a legacy behaviour. It feels very flimsy. + if (Alpha > 1) Alpha %= 1; + } + [Resolved] private ISkinSource skin { get; set; } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index e25c915d8b6c..03138710cccc 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -74,6 +74,21 @@ protected override Vector2 DrawScale public override bool IsPresent => !float.IsNaN(DrawPosition.X) && !float.IsNaN(DrawPosition.Y) && base.IsPresent; + protected override void Update() + { + base.Update(); + + // In stable, alpha transforms exceeding values of 1 would result in sprites disappearing from view. + // See https://github.com/peppy/osu-stable-reference/blob/08e3dafd525934cf48880b08e91c24ce4ad8b761/osu!/Graphics/Sprites/pSprite.cs#L413-L414 + // + // Over the years, storyboard(ers) have taken advantage of this to create "flicker" patterns. + // This is quite a common technique, so we are reproducing it here for now. + // + // NOTE TO FUTURE VISITORS: If we do ever update the storyboard spec, we may want to move such flicker effects to their + // own transform type, and make this a legacy behaviour. It feels very flimsy. + if (Alpha > 1) Alpha %= 1; + } + [Resolved] private ISkinSource skin { get; set; } = null!; diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index b57b0daa1bb7..15cd54baadc3 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Framework.Audio.Track; using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; @@ -133,7 +134,7 @@ private ConvertResult convert(string name, Mod[] mods) string afterConversion = beatmap.Serialize(); - Assert.AreEqual(beforeConversion, afterConversion, "Conversion altered original beatmap"); + ClassicAssert.AreEqual(beforeConversion, afterConversion, "Conversion altered original beatmap"); return new ConvertResult { diff --git a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs index 16434406b509..e98c34d603b0 100644 --- a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs +++ b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs @@ -23,16 +23,16 @@ public abstract class DifficultyCalculatorTest protected abstract string ResourceAssembly { get; } - protected void Test(double expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods) + protected void Test(double? expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods) { - var attributes = CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods); + var attributes = CreateDifficultyCalculator(GetBeatmap(name)).Calculate(mods); // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences. Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001)); Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo)); } - private IWorkingBeatmap getBeatmap(string name) + protected IWorkingBeatmap GetBeatmap(string name) { using (var resStream = openResource($"{resource_namespace}.{name}.osu")) using (var stream = new LineBufferedReader(resStream)) diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index 1f491be7e3e5..85c436e9c8e5 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -86,9 +86,7 @@ protected void CreateTestWithBeatmap(string filename) currentTestBeatmap = Decoder.GetDecoder(reader).Decode(reader); // populate ruleset for beatmap converters that require it to be present. - var ruleset = rulesetStore.GetRuleset(currentTestBeatmap.BeatmapInfo.Ruleset.OnlineID); - - Debug.Assert(ruleset != null); + var ruleset = rulesetStore.GetRuleset(currentTestBeatmap.BeatmapInfo.Ruleset.OnlineID) ?? new RulesetInfo { OnlineID = currentTestBeatmap.BeatmapInfo.Ruleset.OnlineID }; currentTestBeatmap.BeatmapInfo.Ruleset = ruleset; }); diff --git a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs index b7803f342010..4cab848648c6 100644 --- a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs @@ -3,7 +3,7 @@ using System; using System.Linq; -using NUnit.Framework; +using NUnit.Framework.Legacy; using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets; @@ -23,11 +23,11 @@ protected void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) { var ruleset = CreateRuleset(); var mods = ruleset.ConvertFromLegacyMods(legacyMods).ToList(); - Assert.AreEqual(expectedMods.Length, mods.Count); + ClassicAssert.AreEqual(expectedMods.Length, mods.Count); foreach (var modType in expectedMods) { - Assert.IsNotNull(mods.SingleOrDefault(mod => mod.GetType() == modType)); + ClassicAssert.NotNull(mods.SingleOrDefault(mod => mod.GetType() == modType)); } } @@ -38,7 +38,7 @@ protected void TestToLegacy(LegacyMods expectedLegacyMods, Type[] providedModTyp .Where(mod => providedModTypes.Contains(mod.GetType())) .ToArray(); var actualLegacyMods = ruleset.ConvertToLegacyMods(modInstances); - Assert.AreEqual(expectedLegacyMods, actualLegacyMods); + ClassicAssert.AreEqual(expectedLegacyMods, actualLegacyMods); } } } diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index caf99a4cf6a9..dfb6a4fff5bc 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -47,6 +47,8 @@ public TestBeatmap(RulesetInfo ruleset, bool withHitObjects = true) BeatmapInfo.Ruleset = ruleset; BeatmapInfo.Length = 75000; + BeatmapInfo.BPM = 123; + BeatmapInfo.StarRating = 4.32; BeatmapInfo.OnlineInfo = new APIBeatmap(); BeatmapInfo.OnlineID = Interlocked.Increment(ref onlineBeatmapID); BeatmapInfo.Status = BeatmapOnlineStatus.Ranked; diff --git a/osu.Game/Tests/Visual/EditorSavingTestScene.cs b/osu.Game/Tests/Visual/EditorSavingTestScene.cs index 8d27618c00a0..5a3f5c432e59 100644 --- a/osu.Game/Tests/Visual/EditorSavingTestScene.cs +++ b/osu.Game/Tests/Visual/EditorSavingTestScene.cs @@ -13,7 +13,7 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Menu; -using osu.Game.Screens.SelectV2; +using osu.Game.Screens.Select; using osuTK.Input; namespace osu.Game.Tests.Visual diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index dca1b0e46830..5ded0601df58 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -128,6 +128,8 @@ public override Task BeginWatchingMultiplayerRoo public override Task EndWatchingMultiplayerRoom(long id) => Task.CompletedTask; + public override Task RefreshFriends() => Task.CompletedTask; + public void Disconnect() { isConnected.Value = false; diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 5b2876a98977..c6e39016f073 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -19,7 +19,9 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Online.RankedPlay; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; using osu.Game.Tests.Visual.OnlinePlay; @@ -123,6 +125,16 @@ private void addUser(MultiplayerRoomUser user) user.MatchState = new TeamVersusUserState { TeamID = bestTeam }; ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).WaitSafely(); break; + + case RankedPlayRoomState: + ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[user.UserID] = new RankedPlayUserInfo + { + Rating = 1500, + Hand = Enumerable.Range(0, 5).Select(_ => new RankedPlayCardItem()).ToList() + }; + + ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).WaitSafely(); + break; } } @@ -428,6 +440,14 @@ public async Task SendUserMatchRequest(int userId, MatchUserRequest request) Action = avatarAction.Action }).ConfigureAwait(false); break; + + case RankedPlayCardHandReplayRequest cardHandState: + await ((IMultiplayerClient)this).MatchEvent(new RankedPlayCardHandReplayEvent + { + UserId = userId, + Frames = cardHandState.Frames, + }).ConfigureAwait(false); + break; } } @@ -561,6 +581,16 @@ public async Task RemoveUserPlaylistItem(int userId, long playlistItemId) public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(playlistItemId)); + public override Task VoteToSkipIntro() + { + return UserVoteToSkipIntro(api.LocalUser.Value.OnlineID); + } + + public async Task UserVoteToSkipIntro(int userId) + { + await ((IMultiplayerClient)this).UserVotedToSkipIntro(userId, true).ConfigureAwait(false); + } + protected override Task CreateRoomInternal(MultiplayerRoom room) { Room apiRoom = new Room(room) @@ -614,6 +644,28 @@ private async Task changeMatchType(MatchType type) await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); } + break; + + case MatchType.RankedPlay: + ServerRoom.MatchState = new RankedPlayRoomState(); + + foreach (var user in ServerRoom.Users) + { + ((RankedPlayRoomState)ServerRoom.MatchState).Users[user.UserID] = new RankedPlayUserInfo + { + Rating = 1500, + Hand = Enumerable.Range(0, 5).Select(_ => new RankedPlayCardItem()).ToList() + }; + } + + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + + foreach (var user in ServerRoom.Users) + { + user.MatchState = null; + await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); + } + break; } } @@ -772,7 +824,34 @@ public async Task ChangeMatchRoomState(MatchRoomState state) await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); } - public override Task GetMatchmakingPools() + public override Task DiscardCards(RankedPlayCardItem[] cards) + => DiscardCards(_ => cards); + + public Task DiscardCards(Func> selector) + => DiscardUserCards(api.LocalUser.Value.OnlineID, selector); + + public async Task DiscardUserCards(int userId, Func> selector) + { + RankedPlayUserInfo info = ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId]; + RankedPlayCardItem[] cards = selector(info.Hand.ToArray()).ToArray(); + + await RankedPlayRemoveUserCards(userId, _ => cards).ConfigureAwait(false); + await RankedPlayAddUserCards(userId, Enumerable.Range(0, cards.Length).Select(_ => new RankedPlayCardItem()).ToArray()).ConfigureAwait(false); + } + + public override Task PlayCard(RankedPlayCardItem card) + => PlayCard(_ => card); + + public Task PlayCard(Func selector) + => PlayUserCard(api.LocalUser.Value.OnlineID, selector); + + public async Task PlayUserCard(int userId, Func selector) + { + RankedPlayCardItem card = selector(((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.ToArray()); + await ((IRankedPlayClient)this).RankedPlayCardPlayed(clone(card)).ConfigureAwait(false); + } + + public override Task GetMatchmakingPoolsOfType(MatchmakingPoolType type) { return Task.FromResult( [ @@ -855,6 +934,93 @@ await StartCountdown(new MatchmakingStageCountdown }).ConfigureAwait(false); } + /// + /// Adds a card to the local user's hand. + /// + public Task RankedPlayAddCards(RankedPlayCardItem[] cards) + => RankedPlayAddUserCards(api.LocalUser.Value.OnlineID, cards); + + /// + /// Adds a card to the given user's hand. + /// + public async Task RankedPlayAddUserCards(int userId, RankedPlayCardItem[] cards) + { + foreach (var card in cards) + { + ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.Add(card); + await ((IRankedPlayClient)this).RankedPlayCardAdded(userId, clone(card)).ConfigureAwait(false); + } + + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom!.MatchState)).ConfigureAwait(false); + } + + /// + /// Removes a card from the local user's hand. + /// + public Task RankedPlayRemoveCards(Func selector) + => RankedPlayRemoveUserCards(api.LocalUser.Value.OnlineID, selector); + + /// + /// Removes a card from the given user's hand. + /// + public async Task RankedPlayRemoveUserCards(int userId, Func selector) + { + RankedPlayCardItem[] cards = selector(((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.ToArray()); + + foreach (var card in cards) + { + ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.Remove(card); + await ((IRankedPlayClient)this).RankedPlayCardRemoved(userId, clone(card)).ConfigureAwait(false); + } + + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom!.MatchState)).ConfigureAwait(false); + } + + /// + /// Reveals a card in the local user's hand. + /// + public Task RankedPlayRevealCard(Func selector, MultiplayerPlaylistItem item) + => RankedPlayRevealUserCard(api.LocalUser.Value.OnlineID, selector, item); + + /// + /// Reveals a card in the given user's hand. + /// + public async Task RankedPlayRevealUserCard(int userId, Func selector, MultiplayerPlaylistItem item) + { + RankedPlayCardItem card = selector(((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId].Hand.ToArray()); + await ((IRankedPlayClient)this).RankedPlayCardRevealed(clone(card), clone(item)).ConfigureAwait(false); + } + + public async Task RankedPlayChangeStage(RankedPlayStage stage, Action? prepare = null) + { + RankedPlayRoomState state = clone((RankedPlayRoomState)ServerRoom!.MatchState!); + + state.Stage = stage; + + if (stage == RankedPlayStage.RoundWarmup) + state.CurrentRound++; + + prepare?.Invoke(state); + + await ChangeMatchRoomState(state).ConfigureAwait(false); + await StartCountdown(new RankedPlayStageCountdown + { + Stage = stage, + TimeRemaining = TimeSpan.FromSeconds(stage == RankedPlayStage.CardPlay ? 30 : 10) + }).ConfigureAwait(false); + } + + public async Task RankedPlayChangeUserState(int userId, Action prepare) + { + Debug.Assert(ServerRoom != null); + + var userInfo = clone(((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId]); + prepare(userInfo); + + ((RankedPlayRoomState)ServerRoom!.MatchState!).Users[userId] = userInfo; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom!.MatchState)).ConfigureAwait(false); + } + #region API Room Handling public IReadOnlyList ServerSideRooms diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs index 75932bbfef70..3aa126c2500c 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs @@ -46,7 +46,7 @@ protected OnlinePlayTestScene() }); } - protected sealed override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DelegatedDependencyContainer(base.CreateChildDependencies(parent)); public override void SetUpSteps() diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 9b0b66a18ca6..8c2ba938833b 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -274,6 +274,8 @@ public static APIBeatmapSet CreateAPIBeatmapSet(IBeatmapInfo original) var result = new APIBeatmapSet { + Genre = new BeatmapSetOnlineGenre { Id = 15, Name = "Future genre" }, + Language = new BeatmapSetOnlineLanguage { Id = 15, Name = "Future language" }, OnlineID = original.BeatmapSet.OnlineID, Status = BeatmapOnlineStatus.Ranked, Covers = new BeatmapSetOnlineCovers @@ -293,6 +295,34 @@ public static APIBeatmapSet CreateAPIBeatmapSet(IBeatmapInfo original) }, Source = original.Metadata.Source, Tags = original.Metadata.Tags, + BPM = original.BPM, + HasFavourited = false, + PlayCount = 123, + FavouriteCount = 456, + Submitted = DateTime.Now, + Ranked = DateTime.Now, + Ratings = Enumerable.Range(0, 11).ToArray(), + RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ], Beatmaps = new[] { new APIBeatmap @@ -305,10 +335,30 @@ public static APIBeatmapSet CreateAPIBeatmapSet(IBeatmapInfo original) RulesetID = original.Ruleset.OnlineID, StarRating = original.StarRating, DifficultyName = original.DifficultyName, + CircleSize = original.Difficulty.CircleSize, + DrainRate = original.Difficulty.DrainRate, + OverallDifficulty = original.Difficulty.OverallDifficulty, + ApproachRate = original.Difficulty.ApproachRate, + Length = original.Length, + HitLength = original.Length, + CircleCount = 111, + SliderCount = 12, + PlayCount = 222, + BPM = original.BPM, + PassCount = 21, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, + TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ], } - }, - HasFavourited = false, - FavouriteCount = 0, + } }; foreach (var beatmap in result.Beatmaps) diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index a644936a169f..b23fda81906c 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -10,6 +10,7 @@ using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -55,6 +56,7 @@ protected virtual IBeatmap GetPlayableBeatmap() var playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo); playable.BeatmapInfo.Ruleset = rulesetInfo; playable.Difficulty.CircleSize = 2; + playable.ControlPointInfo.Add(0, new TimingControlPoint()); return playable; } diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index 7d28ee1d1d55..3d106bc5b7a6 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -43,28 +43,29 @@ protected ScreenTestScene() base.Content.AddRange(new Drawable[] { backReceptor = new ScreenFooter.BackReceptor(), - Stack = new OsuScreenStack - { - Name = nameof(ScreenTestScene), - RelativeSizeAxes = Axes.Both - }, new PopoverContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { + Stack = new OsuScreenStack + { + Name = nameof(ScreenTestScene), + RelativeSizeAxes = Axes.Both + }, + // TODO: is this ever used? it probably shouldn't be. content = new Container { RelativeSizeAxes = Axes.Both }, + overlayContent = new Container + { + RelativeSizeAxes = Axes.Both, + Child = DialogOverlay = new DialogOverlay() + }, screenStackFooter = new ScreenStackFooter(Stack, backReceptor) { - BackButtonPressed = () => Stack.Exit() + BackButtonPressed = BackButtonPressed, } } }, - overlayContent = new Container - { - RelativeSizeAxes = Axes.Both, - Child = DialogOverlay = new DialogOverlay() - }, }); ScreenFooter = screenStackFooter.Footer; @@ -73,6 +74,8 @@ protected ScreenTestScene() Stack.ScreenExited += (_, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed ← {newScreen}"); } + protected virtual void BackButtonPressed() => Stack.Exit(); + protected void LoadScreen(OsuScreen screen) => Stack.Push(screen); [SetUpSteps] diff --git a/osu.Game/Updater/MobileUpdateNotifier.cs b/osu.Game/Updater/MobileUpdateNotifier.cs index 3a290c9a63fe..43e4df53241c 100644 --- a/osu.Game/Updater/MobileUpdateNotifier.cs +++ b/osu.Game/Updater/MobileUpdateNotifier.cs @@ -9,8 +9,10 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Online.API; namespace osu.Game.Updater @@ -57,8 +59,7 @@ protected override async Task PerformUpdateCheck(CancellationToken cancell { Notifications.Post(new UpdateAvailableNotification(cancellationToken) { - Text = $"A newer release of osu! has been found ({version} → {latestTagName}).\n\n" - + "Click here to download the new version, which can be installed over the top of your existing installation", + Text = LocalisableString.Interpolate($"{NotificationsStrings.UpdateAvailable(version, latestTagName)}\n\n{NotificationsStrings.UpdateAvailableManualInstall}"), Icon = FontAwesome.Solid.Download, Activated = () => { diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 0710797b60d5..bc9d00b80446 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -6,7 +6,9 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Online.API; namespace osu.Game.Updater @@ -51,8 +53,7 @@ protected override async Task PerformUpdateCheck(CancellationToken cancell { Notifications.Post(new UpdateAvailableNotification(cancellationToken) { - Text = $"A newer release of osu! has been found ({version} → {latestTagName}).\n\n" - + "Check with your package manager / provider to bring osu! up-to-date!", + Text = LocalisableString.Interpolate($"{NotificationsStrings.UpdateAvailable(version, latestTagName)}\n\n{NotificationsStrings.UpdateAvailablePackageManaged}"), }); return true; diff --git a/osu.Game/Users/Drawables/ClickableTeamFlag.cs b/osu.Game/Users/Drawables/ClickableTeamFlag.cs new file mode 100644 index 000000000000..69d592054e5c --- /dev/null +++ b/osu.Game/Users/Drawables/ClickableTeamFlag.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Users.Drawables +{ + public partial class ClickableTeamFlag : OsuClickableContainer + { + private readonly APITeam? team; + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + /// + /// A clickable flag component for the specified team, with UI sounds and a tooltip. + /// + /// The team. A null value will show a placeholder background. + /// If set to true, the team's name is displayed in the tooltip. + public ClickableTeamFlag(APITeam? team, bool showTooltipOnHover = true) + { + this.team = team; + + if (team == null) + return; + + Action = openProfile; + + if (showTooltipOnHover) + TooltipText = team.Name; + } + + [BackgroundDependencyLoader] + private void load() + { + LoadComponentAsync(new DrawableTeamFlag(team) { RelativeSizeAxes = Axes.Both }, Add); + } + + private void openProfile() + { + if (team != null) + game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/teams/{team.Id}"); + } + } +} diff --git a/osu.Game/Users/Drawables/DrawableTeamFlag.cs b/osu.Game/Users/Drawables/DrawableTeamFlag.cs new file mode 100644 index 000000000000..27b5f447a5ac --- /dev/null +++ b/osu.Game/Users/Drawables/DrawableTeamFlag.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Users.Drawables +{ + [LongRunningLoad] + public partial class DrawableTeamFlag : CompositeDrawable + { + private readonly APITeam? team; + + private readonly Sprite sprite; + + /// + /// A simple, non-interactable flag sprite for the specified user. + /// + /// The team. A null value will show a placeholder background. + public DrawableTeamFlag(APITeam? team) + { + this.team = team; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.FromHex("333"), + }, + sprite = new Sprite + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fit, + } + }; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + if (team != null) + sprite.Texture = textures.Get(team.FlagUrl); + } + } +} diff --git a/osu.Game/Users/Drawables/UpdateableTeamFlag.cs b/osu.Game/Users/Drawables/UpdateableTeamFlag.cs index 517eb589b9d6..adaa192451b6 100644 --- a/osu.Game/Users/Drawables/UpdateableTeamFlag.cs +++ b/osu.Game/Users/Drawables/UpdateableTeamFlag.cs @@ -1,17 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; +using osu.Framework.Graphics.Effects; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Users.Drawables @@ -31,80 +23,86 @@ public APITeam? Team } } - protected override double LoadDelay => 200; - - public UpdateableTeamFlag(APITeam? team = null) + public new bool Masking { - Team = team; - - Masking = true; + get => base.Masking; + set => base.Masking = value; } - protected override Drawable? CreateDrawable(APITeam? team) - { - if (team == null) - return Empty(); + private bool useDefaultRadius = true; - return new TeamFlag(team) { RelativeSizeAxes = Axes.Both }; + public new float CornerRadius + { + get => base.CornerRadius; + set + { + useDefaultRadius = false; + base.CornerRadius = value; + } } - // Generally we just want team flags to disappear if the user doesn't have one. - // This also handles fill flow cases and avoids spacing being added for non-displaying flags. - public override bool IsPresent => base.IsPresent && Team != null; - - protected override void Update() + public new EdgeEffectParameters EdgeEffect { - base.Update(); - - CornerRadius = DrawHeight / 8; + get => base.EdgeEffect; + set => base.EdgeEffect = value; } - [LongRunningLoad] - public partial class TeamFlag : CompositeDrawable, IHasTooltip - { - private readonly APITeam team; + protected override double LoadDelay => 200; - public LocalisableString TooltipText { get; } + private readonly bool isInteractive; + private readonly bool hideOnNull; + private readonly bool showTooltipOnHover; + + /// + /// Construct a new UpdateableTeamFlag. + /// + /// The initial team to display. + /// If set to true, hover/click sounds will play and clicking the flag will open the team's profile. + /// + /// If set to true, the team's name is displayed in the tooltip. + /// Only has an effect if is true. + /// + /// Whether to hide the flag when the provided team is null. + public UpdateableTeamFlag(APITeam? team = null, bool isInteractive = true, bool hideOnNull = true, bool showTooltipOnHover = true) + { + this.isInteractive = isInteractive; + this.hideOnNull = hideOnNull; + this.showTooltipOnHover = showTooltipOnHover; - [Resolved] - private OsuGame? game { get; set; } + Team = team; - [Resolved] - private IAPIProvider api { get; set; } = null!; + Masking = true; + } - public TeamFlag(APITeam team) - { - this.team = team; - TooltipText = team.Name; - } + protected override Drawable? CreateDrawable(APITeam? team) + { + if (team == null && hideOnNull) + return Empty(); - [BackgroundDependencyLoader] - private void load(LargeTextureStore textures) + if (isInteractive) { - InternalChildren = new Drawable[] + return new ClickableTeamFlag(team, showTooltipOnHover) { - new HoverClickSounds(), - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Colour4.FromHex("333"), - }, - new Sprite - { - RelativeSizeAxes = Axes.Both, - Texture = textures.Get(team.FlagUrl), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fit, - } + RelativeSizeAxes = Axes.Both, }; } - protected override bool OnClick(ClickEvent e) + return new DrawableTeamFlag(team) { - game?.OpenUrlExternally($"{api.Endpoints.WebsiteUrl}/teams/{team.Id}"); - return true; - } + RelativeSizeAxes = Axes.Both, + }; } + + protected override void Update() + { + base.Update(); + + if (useDefaultRadius) + base.CornerRadius = DrawHeight / 8; + } + + // Generally we just want team flags to disappear if the user doesn't have one. + // This also handles fill flow cases and avoids spacing being added for non-displaying flags. + public override bool IsPresent => base.IsPresent && (Team != null || !hideOnNull); } } diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 86c84c0bb2eb..d9aa772f661f 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -274,15 +274,22 @@ public InLobby(Room room) public InLobby(MultiplayerRoom room) { - if (room.Settings.MatchType == MatchType.Matchmaking) + switch (room.Settings.MatchType) { - RoomID = -1; - RoomName = "Quick Play"; - } - else - { - RoomID = room.RoomID; - RoomName = room.Settings.Name; + case MatchType.Matchmaking: + RoomID = -1; + RoomName = "Quick Play"; + break; + + case MatchType.RankedPlay: + RoomID = -1; + RoomName = "Ranked Play"; + break; + + default: + RoomID = room.RoomID; + RoomName = room.Settings.Name; + break; } } diff --git a/osu.Game/Users/UserBrickPanel.cs b/osu.Game/Users/UserBrickPanel.cs index b92c9a9afdc7..fff1307e0a95 100644 --- a/osu.Game/Users/UserBrickPanel.cs +++ b/osu.Game/Users/UserBrickPanel.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -21,11 +20,8 @@ public UserBrickPanel(APIUser user) CornerRadius = 6; } - [BackgroundDependencyLoader] - private void load() - { - Background.FadeTo(0.2f); - } + // Matches osu!web styling. + protected override Drawable? CreateBackground() => Empty(); protected override Drawable CreateLayout() => new FillFlowContainer { diff --git a/osu.Game/Utils/FilesystemSanityCheckHelpers.cs b/osu.Game/Utils/FilesystemSanityCheckHelpers.cs new file mode 100644 index 000000000000..d73475081052 --- /dev/null +++ b/osu.Game/Utils/FilesystemSanityCheckHelpers.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; + +namespace osu.Game.Utils +{ + public static class FilesystemSanityCheckHelpers + { + /// + /// Returns whether is potentially susceptible to path traversal style attacks. + /// + public static bool IncursPathTraversalRisk(string path) + => path.Contains("../", StringComparison.Ordinal) || path.Contains("..\\", StringComparison.Ordinal) || Path.IsPathRooted(path); + + /// + /// Returns whether is a subdirectory (direct or nested) of . + /// + public static bool IsSubDirectory(string parent, string child) + { + // `Path.GetFullPath()` invocations are required to fully resolve the paths to unambiguous downwards-traversal-only paths. + var parentInfo = new DirectoryInfo(Path.GetFullPath(parent)); + var childInfo = new DirectoryInfo(Path.GetFullPath(child)); + + while (childInfo != null) + { + if (parentInfo.FullName == childInfo.FullName) + return true; + + childInfo = childInfo.Parent; + } + + return false; + } + } +} diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index 29e402144a75..823c6b376dd7 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -2,9 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using Humanizer; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; +using osu.Game.Extensions; +using osu.Game.Localisation; namespace osu.Game.Utils { @@ -73,5 +76,84 @@ public static int FindPrecision(decimal d) /// The base BPM to round. /// Rate adjustment, if applicable. public static int RoundBPM(double baseBpm, double rate = 1) => (int)Math.Round(baseBpm * rate); + + public static LocalisableString ToLocalisedMediumDate(this DateTimeOffset dateTime) + => new LocalisableString(new MediumFormattedDate(dateTime)); + + /// + /// This class is supposed to provide date formatting roughly equivalent to + /// + /// moment().format('ll'); + /// + /// which is used in several places on the website, and as such needs to be mirrored to the relevant game overlays reimplementing those places. + /// + private class MediumFormattedDate : ILocalisableStringData + { + public readonly DateTimeOffset Date; + + public MediumFormattedDate(DateTimeOffset date) + { + Date = date; + } + + public bool Equals(ILocalisableStringData? other) + => other is MediumFormattedDate date && Date.Equals(date.Date); + + // reference: individual language files in https://github.com/moment/moment/tree/18aba135ab927ffe7f868ee09276979bed6993a6/locale + private static readonly Dictionary format_mapping = new Dictionary + { + [Language.en] = @"d MMM yyyy", + [Language.be] = @"d MMM yyyy 'г.'", + [Language.bg] = @"d MMM yyyy", + [Language.ca] = @"d MMM yyyy", + [Language.cs] = @"d. MMM yyyy", + [Language.da] = @"d. MMM yyyy", + [Language.de] = @"d. MMM yyyy", + [Language.el] = @"d MMM yyyy", + [Language.es] = @"d 'de' MMM 'de' yyyy", + [Language.fi] = @"d. MMM yyyy", + [Language.fr] = @"d MMM yyyy", + [Language.hr_hr] = @"d. MMM yyyy", + [Language.hu] = @"yyyy. MMM d.", + [Language.id] = @"d MMM yyyy", + [Language.it] = @"d MMM yyyy", + [Language.ja] = @"yyyy年M月d日", + [Language.ko] = @"yyyy년 MMMM d일", + [Language.lt] = @"yyyy 'm.' MMM d 'd.'", + [Language.lv_lv] = @"yyyy. 'gada' d. MMM", + [Language.ms_my] = @"d MMM yyyy", + [Language.nl] = @"d MMM yyyy", + [Language.no] = @"d. MMM yyyy", // look under `nb` (Norsk Bokmål) and `nn` (Nynorsk) in momentjs source + [Language.pl] = @"d MMM yyyy", + [Language.pt] = @"d 'de' MMM 'de' yyyy", + [Language.pt_br] = @"d 'de' MMM 'de' yyyy", + [Language.ro] = @"d MMM yyyy", + [Language.ru] = @"d MMM yyyy 'г.'", + [Language.sk] = @"d. MMM yyyy", + [Language.sl] = @"d. MMM yyyy", + [Language.sr] = @"d. MMM yyyy.", + [Language.sv] = @"d MMM yyyy", + [Language.th] = @"d MMM yyyy", + [Language.tr] = @"d MMM yyyy", + [Language.uk] = @"d MMM yyyy 'р.'", + [Language.vi] = @"d MMM yyyy", + [Language.zh] = @"yyyy年M月d日", + [Language.zh_hant] = @"yyyy年M月d日", + }; + + public string GetLocalised(LocalisationParameters parameters) + { + string? cultureCode = parameters.Store?.EffectiveCulture.Name.ToLowerInvariant(); + + if (!string.IsNullOrEmpty(cultureCode) + && LanguageExtensions.TryParseCultureCode(cultureCode, out var language) + && format_mapping.TryGetValue(language, out string? format)) + { + return Date.ToString(format); + } + + return Date.ToString(@"d MMM yyyy"); + } + } } } diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index 4f916f810e15..3cc23264fb57 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -2,10 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,6 +21,7 @@ using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Models; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Rulesets; @@ -41,6 +46,9 @@ public SentryLogger(OsuGame game, Storage? storage = null) { this.game = game; + if (Environment.GetEnvironmentVariable("OSU_DISABLE_ERROR_REPORTING") == "1") + return; + if (!game.IsDeployedBuild || !game.CreateEndpoints().WebsiteUrl.EndsWith(@".ppy.sh", StringComparison.Ordinal)) return; @@ -171,6 +179,7 @@ private void processLogEntry(LogEntry entry) scope.SetTag(@"beatmap", $"{beatmap.OnlineID}"); scope.SetTag(@"ruleset", ruleset.ShortName); scope.SetTag(@"os", $"{RuntimeInfo.OS} ({Environment.OSVersion})"); + scope.SetTag(@"version hash", game.VersionHash); scope.SetTag(@"processor count", Environment.ProcessorCount.ToString()); }); } @@ -220,34 +229,61 @@ private SentryLevel getSentryLevel(LogLevel entryLevel) } } + private static readonly HashSet ignored_io_exception_hresults = + [ + // see https://stackoverflow.com/a/9294382 for how these are synthesised + unchecked((int)0x80070020), // ERROR_SHARING_VIOLATION + unchecked((int)0x80070027), // ERROR_HANDLE_DISK_FULL + unchecked((int)0x80070070), // ERROR_DISK_FULL + ]; + private bool shouldSubmitException(Exception exception) { + if (IsLocalUserConnectivityException(exception)) + return false; + switch (exception) { - case IOException ioe: - // disk full exceptions, see https://stackoverflow.com/a/9294382 - const int hr_error_handle_disk_full = unchecked((int)0x80070027); - const int hr_error_disk_full = unchecked((int)0x80070070); + // disk I/O failures, invalid formats, etc. - if (ioe.HResult == hr_error_handle_disk_full || ioe.HResult == hr_error_disk_full) + case IOException ioe: + if (ignored_io_exception_hresults.Contains(ioe.HResult)) return false; break; - case WebException we: - switch (we.Status) - { - // more statuses may need to be blocked as we come across them. - case WebExceptionStatus.Timeout: - return false; - } + case UnauthorizedAccessException: + case SharpCompress.Common.InvalidFormatException: + return false; - break; + // stuff that should really never make it to sentry + case APIAccess.WebRequestFlushedException: + case TaskCanceledException: + return false; } return true; } + public static bool IsLocalUserConnectivityException(Exception exception) + { + switch (exception) + { + case TimeoutException te: + return te.Message.Contains(@"elapsed without receiving a message from the server"); + + case WebException we: + // more statuses may need to be blocked as we come across them. + return we.Status == WebExceptionStatus.Timeout; + + case WebSocketException: + case SocketException: + return true; + } + + return false; + } + #region Disposal public void Dispose() diff --git a/osu.Game/Utils/ZipUtils.cs b/osu.Game/Utils/ZipUtils.cs index eb2d2d3b8022..8eb4c0491477 100644 --- a/osu.Game/Utils/ZipUtils.cs +++ b/osu.Game/Utils/ZipUtils.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Linq; using SharpCompress.Archives.Zip; namespace osu.Game.Utils @@ -15,7 +16,7 @@ public static bool IsZipArchive(MemoryStream stream) { stream.Seek(0, SeekOrigin.Begin); - using (var arc = ZipArchive.Open(stream)) + using (var arc = ZipArchive.OpenArchive(stream)) { foreach (var entry in arc.Entries) { @@ -23,9 +24,15 @@ public static bool IsZipArchive(MemoryStream stream) { } } - } - return true; + // aside from opening every zip entry not failing, we also require there to *be* at least one entry. + // if there are no entries, the best case is that it's an actual empty zip + // and as such probably useless to whatever wants to use it later. + // the worst case is that it's actually *not* a zip and instead a stream of binary + // which *accidentally* happened to contain the magic sequence of bytes for the zip header (50 4b 05 06), + // and if that's the case, then we are *misclassifying* it as a zip by returning `true` unconditionally. + return arc.Entries.Any(); + } } catch (Exception) { @@ -44,7 +51,7 @@ public static bool IsZipArchive(string path) try { - using (var arc = ZipArchive.Open(path)) + using (var arc = ZipArchive.OpenArchive(path)) { foreach (var entry in arc.Entries) { @@ -52,9 +59,15 @@ public static bool IsZipArchive(string path) { } } - } - return true; + // aside from opening every zip entry not failing, we also require there to *be* at least one entry. + // if there are no entries, the best case is that it's an actual empty zip + // and as such probably useless to whatever wants to use it later. + // the worst case is that it's actually *not* a zip and instead a stream of binary + // which *accidentally* happened to contain the magic sequence of bytes for the zip header (50 4b 05 06), + // and if that's the case, then we are *misclassifying* it as a zip by returning `true` unconditionally. + return arc.Entries.Any(); + } } catch (Exception) { diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 3ca35b958c62..60b3546d8513 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -18,30 +18,34 @@ - - - + + + NU1903 + + + + - - - - - - + + + + + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - - - + + + diff --git a/osu.iOS.props b/osu.iOS.props index 7e219e4b1d44..0338bbfec4c5 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + diff --git a/osu.iOS/AppDelegate.cs b/osu.iOS/AppDelegate.cs index 5d309f2fc171..65c1951e801a 100644 --- a/osu.iOS/AppDelegate.cs +++ b/osu.iOS/AppDelegate.cs @@ -9,7 +9,7 @@ namespace osu.iOS { [Register("AppDelegate")] - public class AppDelegate : GameApplicationDelegate + public class AppDelegate : GameApplicationDelegate, IUIApplicationDelegate { private UIInterfaceOrientationMask? defaultOrientationsMask; private UIInterfaceOrientationMask? orientations; @@ -41,7 +41,7 @@ public UIInterfaceOrientationMask? Orientations protected override Framework.Game CreateGame() => new OsuGameIOS(this); - public override UIInterfaceOrientationMask GetSupportedInterfaceOrientations(UIApplication application, UIWindow forWindow) + public UIInterfaceOrientationMask GetSupportedInterfaceOrientations(UIApplication application, UIWindow forWindow) { if (orientations != null) return orientations.Value; diff --git a/osu.iOS/AppIcon60x60@2x.png b/osu.iOS/AppIcon60x60@2x.png new file mode 100644 index 000000000000..1aba2b14aeca Binary files /dev/null and b/osu.iOS/AppIcon60x60@2x.png differ diff --git a/osu.iOS/AppIcon76x76@2x~ipad.png b/osu.iOS/AppIcon76x76@2x~ipad.png new file mode 100644 index 000000000000..478a1b79bf9f Binary files /dev/null and b/osu.iOS/AppIcon76x76@2x~ipad.png differ diff --git a/osu.iOS/Assets.car b/osu.iOS/Assets.car new file mode 100644 index 000000000000..9bd24cfa1c74 Binary files /dev/null and b/osu.iOS/Assets.car differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png deleted file mode 100644 index 7b62835cdcfa..000000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/osu.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 29df54b4004b..000000000000 --- a/osu.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/osu.iOS/Assets.xcassets/Contents.json b/osu.iOS/Assets.xcassets/Contents.json deleted file mode 100644 index 4caf392f92c9..000000000000 --- a/osu.iOS/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/osu.iOS/Info.plist b/osu.iOS/Info.plist index 120e8caecc63..8d9985d27995 100644 --- a/osu.iOS/Info.plist +++ b/osu.iOS/Info.plist @@ -25,8 +25,6 @@ armv7 - UIRequiresFullScreen - UIStatusBarHidden UIApplicationSupportsIndirectInputEvents @@ -51,8 +49,31 @@ UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft - XSAppIconAssets - Assets.xcassets/AppIcon.appiconset + CFBundleIcons~ipad + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon60x60 + + CFBundleIconName + AppIcon + + + CFBundleIcons + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon60x60 + AppIcon76x76 + + CFBundleIconName + AppIcon + + UTExportedTypeDeclarations diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index 3e8beddaa4d2..04c00b4c3597 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -22,6 +22,11 @@ + + + + +