diff --git a/osu.Android.props b/osu.Android.props index 7060e88026..e30416bc1c 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,7 @@ - - + + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 788e5f82be..ad929bbac3 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -20,7 +20,7 @@ namespace osu.Android [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false, LaunchMode = LaunchMode.SingleInstance)] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")] - [IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] { "application/zip", "application/octet-stream" })] + [IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] { "application/zip", "application/octet-stream", "application/download", "application/x-zip", "application/x-zip-compressed" })] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryBrowsable, Intent.CategoryDefault }, DataSchemes = new[] { "osu", "osump" })] public class OsuGameActivity : AndroidGameActivity { diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index d1515acafa..5909b82c8f 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -18,6 +18,7 @@ using osu.Framework.Screens; using osu.Game.Screens.Menu; using osu.Game.Updater; using osu.Desktop.Windows; +using osu.Game.IO; namespace osu.Desktop { @@ -32,7 +33,7 @@ namespace osu.Desktop noVersionOverlay = args?.Any(a => a == "--no-version-overlay") ?? false; } - public override Storage GetStorageForStableInstall() + public override StableStorage GetStorageForStableInstall() { try { @@ -40,7 +41,7 @@ namespace osu.Desktop { string stablePath = getStableInstallPath(); if (!string.IsNullOrEmpty(stablePath)) - return new DesktopStorage(stablePath, desktopHost); + return new StableStorage(stablePath, desktopHost); } } catch (Exception) diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index 7805bfcefc..ea43d9a54c 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -8,7 +8,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj index 54fddc297e..bf3aba5859 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs index 692e63fa69..e1eceea606 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Replays; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModAutoplay : ModAutoplay { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad!" } }, Replay = new CatchAutoGenerator(beatmap).Generate(), diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs index 3bc1ee5bf5..d53d019e90 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Replays; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModCinema : ModCinema { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad!" } }, Replay = new CatchAutoGenerator(beatmap).Generate(), diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index 438d17dbc5..5f1736450a 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Catch.Mods public class CatchModDifficultyAdjust : ModDifficultyAdjust { [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] - public BindableNumber CircleSize { get; } = new BindableFloat + public BindableNumber CircleSize { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 1, @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Catch.Mods }; [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] - public BindableNumber ApproachRate { get; } = new BindableFloat + public BindableNumber ApproachRate { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 1, @@ -31,6 +31,14 @@ namespace osu.Game.Rulesets.Catch.Mods Value = 5, }; + protected override void ApplyLimits(bool extended) + { + base.ApplyLimits(extended); + + CircleSize.MaxValue = extended ? 11 : 10; + ApproachRate.MaxValue = extended ? 11 : 10; + } + public override string SettingDescription { get 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 d55b4fe08a..fcc0cafefc 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs index c05e979e9a..105d88129c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModAutoplay : ModAutoplay { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus!" } }, Replay = new ManiaAutoGenerator((ManiaBeatmap)beatmap).Generate(), diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs index 02c1fc1b79..064c55ed8d 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModCinema : ModCinema { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus!" } }, Replay = new ManiaAutoGenerator((ManiaBeatmap)beatmap).Generate(), diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index 7e2a8823b6..cbbbacfe19 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -140,11 +140,11 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return animation == null ? null : new LegacyManiaJudgementPiece(result, animation); } - public override SampleChannel GetSample(ISampleInfo sampleInfo) + public override Sample GetSample(ISampleInfo sampleInfo) { // layered hit sounds never play in mania if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered) - return new SampleChannelVirtual(); + return new SampleVirtual(); return Source.GetSample(sampleInfo); } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs new file mode 100644 index 0000000000..856b6554b9 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.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.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Rulesets.Osu.UI; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModAutoplay : OsuModTestScene + { + [Test] + public void TestSpmUnaffectedByRateAdjust() + => runSpmTest(new OsuModDaycore + { + SpeedChange = { Value = 0.88 } + }); + + [Test] + public void TestSpmUnaffectedByTimeRamp() + => runSpmTest(new ModWindUp + { + InitialRate = { Value = 0.7 }, + FinalRate = { Value = 1.3 } + }); + + private void runSpmTest(Mod mod) + { + SpinnerSpmCounter spmCounter = null; + + CreateModTest(new ModTestData + { + Autoplay = true, + Mod = mod, + Beatmap = new Beatmap + { + HitObjects = + { + new Spinner + { + Duration = 2000, + Position = OsuPlayfield.BASE_SIZE / 2 + } + } + }, + PassCondition = () => Player.ScoreProcessor.JudgedHits >= 1 + }); + + AddUntilStep("fetch SPM counter", () => + { + spmCounter = this.ChildrenOfType().SingleOrDefault(); + return spmCounter != null; + }); + + AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCounter.SpinsPerMinute, 477, 5)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index fefe983f97..e2d9f144c0 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.Tests return null; } - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs index 39deba2f57..af67ab5839 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Replays; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -65,10 +67,10 @@ namespace osu.Game.Rulesets.Osu.Tests private class TestAutoMod : OsuModAutoplay { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "Autoplay" } }, - Replay = new MissingAutoGenerator(beatmap).Generate() + Replay = new MissingAutoGenerator(beatmap, mods).Generate() }; } @@ -76,8 +78,8 @@ namespace osu.Game.Rulesets.Osu.Tests { public new OsuBeatmap Beatmap => (OsuBeatmap)base.Beatmap; - public MissingAutoGenerator(IBeatmap beatmap) - : base(beatmap) + public MissingAutoGenerator(IBeatmap beatmap, IReadOnlyList mods) + : base(beatmap, mods) { } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs new file mode 100644 index 0000000000..77a68b714b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs @@ -0,0 +1,491 @@ +// 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.TypeExtensions; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneObjectOrderedHitPolicy : RateAdjustedBeatmapTestScene + { + private const double early_miss_window = 1000; // time after -1000 to -500 is considered a miss + private const double late_miss_window = 500; // time after +500 is considered a miss + + /// + /// Tests clicking a future circle before the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleBeforeFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Miss); + addJudgementOffsetAssert(hitObjects[0], late_miss_window); + } + + /// + /// Tests clicking a future circle at the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleAtFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Miss); + addJudgementOffsetAssert(hitObjects[0], late_miss_window); + } + + /// + /// Tests clicking a future circle after the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleAfterFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle + 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Miss); + addJudgementOffsetAssert(hitObjects[0], late_miss_window); + } + + /// + /// Tests clicking a future circle before the first circle's start time, while the first circle HAS been judged. + /// + [Test] + public void TestClickSecondCircleBeforeFirstCircleTimeWithFirstCircleJudged() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 200, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200 + addJudgementOffsetAssert(hitObjects[0], -200); // time_second_circle - first_circle_time - 100 + } + + /// + /// Tests clicking a future circle after the first circle's start time, while the first circle HAS been judged. + /// + [Test] + public void TestClickSecondCircleAfterFirstCircleTimeWithFirstCircleJudged() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 200, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200 + addJudgementOffsetAssert(hitObjects[1], -100); // time_second_circle - first_circle_time + } + + /// + /// Tests clicking a future circle after a slider's start time, but hitting all slider ticks. + /// + [Test] + public void TestMissSliderHeadAndHitAllSliderTicks() + { + const double time_slider = 1500; + const double time_circle = 1510; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_slider, Position = positionCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_slider + 10, Position = positionSlider, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit); + addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit); + } + + /// + /// Tests clicking hitting future slider ticks before a circle. + /// + [Test] + public void TestHitSliderTicksBeforeCircle() + { + const double time_slider = 1500; + const double time_circle = 1510; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(30); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_circle + late_miss_window - 100, Position = positionCircle, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_circle + late_miss_window - 90, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit); + addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit); + } + + /// + /// Tests clicking a future circle before a spinner. + /// + [Test] + public void TestHitCircleBeforeSpinner() + { + const double time_spinner = 1500; + const double time_circle = 1800; + Vector2 positionCircle = Vector2.Zero; + + var hitObjects = new List + { + new TestSpinner + { + StartTime = time_spinner, + Position = new Vector2(256, 192), + EndTime = time_spinner + 1000, + }, + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_spinner - 100, Position = positionCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + } + + [Test] + public void TestHitSliderHeadBeforeHitCircle() + { + const double time_circle = 1000; + const double time_slider = 1200; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_circle - 100, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_circle, Position = positionCircle, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + } + + private void addJudgementAssert(OsuHitObject hitObject, HitResult result) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", + () => judgementResults.Single(r => r.HitObject == hitObject).Type == result); + } + + private void addJudgementAssert(string name, Func hitObject, HitResult result) + { + AddAssert($"{name} judgement is {result}", + () => judgementResults.Single(r => r.HitObject == hitObject()).Type == result); + } + + private void addJudgementOffsetAssert(OsuHitObject hitObject, double offset) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judged at {offset}", + () => Precision.AlmostEquals(judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, offset, 100)); + } + + private ScoreAccessibleReplayPlayer currentPlayer; + private List judgementResults; + + private void performTest(List hitObjects, List frames) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + HitObjects = hitObjects, + BeatmapInfo = + { + BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 3 }, + Ruleset = new OsuRuleset().RulesetInfo + }, + }); + + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + + SelectedMods.Value = new[] { new OsuModClassic() }; + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) judgementResults.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + judgementResults = new List(); + }); + + AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); + AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); + } + + private class TestHitCircle : HitCircle + { + protected override HitWindows CreateHitWindows() => new TestHitWindows(); + } + + private class TestSlider : Slider + { + public TestSlider() + { + DefaultsApplied += _ => + { + HeadCircle.HitWindows = new TestHitWindows(); + TailCircle.HitWindows = new TestHitWindows(); + + HeadCircle.HitWindows.SetDifficulty(0); + TailCircle.HitWindows.SetDifficulty(0); + }; + } + } + + private class TestSpinner : Spinner + { + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + { + base.ApplyDefaultsToSelf(controlPointInfo, difficulty); + SpinsRequired = 1; + } + } + + private class TestHitWindows : HitWindows + { + private static readonly DifficultyRange[] ranges = + { + new DifficultyRange(HitResult.Great, 500, 500, 500), + new DifficultyRange(HitResult.Miss, early_miss_window, early_miss_window, early_miss_window), + }; + + public override bool IsHitResultAllowed(HitResult result) => result == HitResult.Great || result == HitResult.Miss; + + protected override DifficultyRange[] GetRanges() => ranges; + } + + private class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, new PlayerConfiguration + { + AllowPause = false, + ShowResults = false, + }) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs index 10baca438d..8dbb48c048 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -162,7 +162,7 @@ namespace osu.Game.Rulesets.Osu.Tests public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; - public SampleChannel GetSample(ISampleInfo sampleInfo) => null; + public Sample GetSample(ISampleInfo sampleInfo) => null; public TValue GetValue(Func query) where TConfiguration : SkinConfiguration => default; public IBindable GetConfig(TLookup lookup) => null; 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 345c3e6d35..b4c686ccea 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs index a0392fe536..dec9cd8622 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { base.Update(); - CirclePiece.UpdateFrom(position == SliderPosition.Start ? HitObject.HeadCircle : HitObject.TailCircle); + CirclePiece.UpdateFrom(position == SliderPosition.Start ? (HitCircle)HitObject.HeadCircle : HitObject.TailCircle); } // Todo: This is temporary, since the slider circle masks don't do anything special yet. In the future they will handle input. diff --git a/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs new file mode 100644 index 0000000000..a088696784 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Judgements +{ + public class SliderTickJudgement : OsuJudgement + { + public override HitResult MaxResult => HitResult.LargeTickHit; + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 8c819c4773..77de0cb45b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Mods inputManager.AllowUserCursorMovement = false; // Generate the replay frames the cursor should follow - replayFrames = new OsuAutoGenerator(drawableRuleset.Beatmap).Generate().Frames.Cast().ToList(); + replayFrames = new OsuAutoGenerator(drawableRuleset.Beatmap, drawableRuleset.Mods).Generate().Frames.Cast().ToList(); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs index bea2bbcb32..3b1f271d41 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -16,10 +17,10 @@ namespace osu.Game.Rulesets.Osu.Mods { public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).Append(typeof(OsuModSpunOut)).ToArray(); - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "Autoplay" } }, - Replay = new OsuAutoGenerator(beatmap).Generate() + Replay = new OsuAutoGenerator(beatmap, mods).Generate() }; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs index 5d9a524577..df06988b70 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -16,10 +17,10 @@ namespace osu.Game.Rulesets.Osu.Mods { public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).Append(typeof(OsuModSpunOut)).ToArray(); - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "Autoplay" } }, - Replay = new OsuAutoGenerator(beatmap).Generate() + Replay = new OsuAutoGenerator(beatmap, mods).Generate() }; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs new file mode 100644 index 0000000000..5470d0fcb4 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs @@ -0,0 +1,86 @@ +// 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.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModClassic : Mod, IApplicableToHitObject, IApplicableToDrawableHitObjects, IApplicableToDrawableRuleset + { + public override string Name => "Classic"; + + public override string Acronym => "CL"; + + public override double ScoreMultiplier => 1; + + public override IconUsage? Icon => FontAwesome.Solid.History; + + public override string Description => "Feeling nostalgic?"; + + public override bool Ranked => false; + + public override ModType Type => ModType.Conversion; + + [SettingSource("No slider head accuracy requirement", "Scores sliders proportionally to the number of ticks hit.")] + public Bindable NoSliderHeadAccuracy { get; } = new BindableBool(true); + + [SettingSource("No slider head movement", "Pins slider heads at their starting position, regardless of time.")] + public Bindable NoSliderHeadMovement { get; } = new BindableBool(true); + + [SettingSource("Apply classic note lock", "Applies note lock to the full hit window.")] + public Bindable ClassicNoteLock { get; } = new BindableBool(true); + + [SettingSource("Use fixed slider follow circle hit area", "Makes the slider follow circle track its final size at all times.")] + public Bindable FixedFollowCircleHitArea { get; } = new BindableBool(true); + + public void ApplyToHitObject(HitObject hitObject) + { + switch (hitObject) + { + case Slider slider: + slider.OnlyJudgeNestedObjects = !NoSliderHeadAccuracy.Value; + + foreach (var head in slider.NestedHitObjects.OfType()) + head.JudgeAsNormalHitCircle = !NoSliderHeadAccuracy.Value; + + break; + } + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var osuRuleset = (DrawableOsuRuleset)drawableRuleset; + + if (ClassicNoteLock.Value) + osuRuleset.Playfield.HitPolicy = new ObjectOrderedHitPolicy(); + } + + public void ApplyToDrawableHitObjects(IEnumerable drawables) + { + foreach (var obj in drawables) + { + switch (obj) + { + case DrawableSlider slider: + slider.Ball.InputTracksVisualSize = !FixedFollowCircleHitArea.Value; + break; + + case DrawableSliderHead head: + head.TrackFollowCircle = !NoSliderHeadMovement.Value; + break; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index a638234dbd..1cb25edecf 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods public class OsuModDifficultyAdjust : ModDifficultyAdjust { [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] - public BindableNumber CircleSize { get; } = new BindableFloat + public BindableNumber CircleSize { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 0, @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods }; [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] - public BindableNumber ApproachRate { get; } = new BindableFloat + public BindableNumber ApproachRate { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 0, @@ -31,6 +31,14 @@ namespace osu.Game.Rulesets.Osu.Mods Value = 5, }; + protected override void ApplyLimits(bool extended) + { + base.ApplyLimits(extended); + + CircleSize.MaxValue = extended ? 11 : 10; + ApproachRate.MaxValue = extended ? 11 : 10; + } + public override string SettingDescription { get diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index 6e7b1050cb..5541d0e790 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -110,8 +110,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections double startTime = start.GetEndTime(); double duration = end.StartTime - startTime; + // Preempt time can go below 800ms. 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 preempt function (see: OsuHitObject). + // Note that this doesn't exactly match the AR>10 visuals as they're classically known, but it feels good. + double preempt = PREEMPT * Math.Min(1, start.TimePreempt / OsuHitObject.PREEMPT_MIN); + fadeOutTime = startTime + fraction * duration; - fadeInTime = fadeOutTime - PREEMPT; + fadeInTime = fadeOutTime - preempt; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 3c0260f5f5..77094f928b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -123,7 +123,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return; } - var result = HitObject.HitWindows.ResultFor(timeOffset); + var result = ResultFor(timeOffset); if (result == HitResult.None || CheckHittable?.Invoke(this, Time.Current) == false) { @@ -146,6 +146,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }); } + /// + /// Retrieves the for a time offset. + /// + /// The time offset. + /// The hit result, or if doesn't result in a judgement. + protected virtual HitResult ResultFor(double timeOffset) => HitObject.HitWindows.ResultFor(timeOffset); + protected override void UpdateInitialTransforms() { base.UpdateInitialTransforms(); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 13f5960bd4..79655c33e4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (JudgedObject?.HitObject is OsuHitObject osuObject) { - Position = osuObject.StackedPosition; + Position = osuObject.StackedEndPosition; Scale = new Vector2(osuObject.Scale); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 511cbc2347..9122f347d0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; using osuTK.Graphics; using osu.Game.Skinning; @@ -30,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public SliderBall Ball { get; private set; } public SkinnableDrawable Body { get; private set; } - public override bool DisplayResult => false; + public override bool DisplayResult => !HitObject.OnlyJudgeNestedObjects; private PlaySliderBody sliderBody => Body.Drawable as PlaySliderBody; @@ -249,7 +250,30 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (userTriggered || Time.Current < HitObject.EndTime) return; - ApplyResult(r => r.Type = NestedHitObjects.Any(h => h.Result.IsHit) ? r.Judgement.MaxResult : r.Judgement.MinResult); + // If only the nested hitobjects are judged, then the slider's own judgement is ignored for scoring purposes. + // But the slider needs to still be judged with a reasonable hit/miss result for visual purposes (hit/miss transforms, etc). + if (HitObject.OnlyJudgeNestedObjects) + { + ApplyResult(r => r.Type = NestedHitObjects.Any(h => h.Result.IsHit) ? r.Judgement.MaxResult : r.Judgement.MinResult); + return; + } + + // Otherwise, if this slider also needs to be judged, apply judgement proportionally to the number of nested hitobjects hit. This is the classic osu!stable scoring. + ApplyResult(r => + { + int totalTicks = NestedHitObjects.Count; + int hitTicks = NestedHitObjects.Count(h => h.IsHit); + + if (hitTicks == totalTicks) + r.Type = HitResult.Great; + else if (hitTicks == 0) + r.Type = HitResult.Miss; + else + { + double hitFraction = (double)hitTicks / totalTicks; + r.Type = hitFraction >= 0.5 ? HitResult.Ok : HitResult.Meh; + } + }); } public override void PlaySamples() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index acc95ab036..01c0d988ee 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -7,16 +7,27 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableSliderHead : DrawableHitCircle { + public new SliderHeadCircle HitObject => (SliderHeadCircle)base.HitObject; + [CanBeNull] public Slider Slider => DrawableSlider?.HitObject; protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; + public override bool DisplayResult => HitObject?.JudgeAsNormalHitCircle ?? base.DisplayResult; + + /// + /// Makes this track the follow circle when the start time is reached. + /// If false, this will be pinned to its initial position in the slider. + /// + public bool TrackFollowCircle = true; + private readonly IBindable pathVersion = new Bindable(); protected override OsuSkinComponents CirclePieceComponent => OsuSkinComponents.SliderHeadHitCircle; @@ -59,12 +70,28 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.Update(); Debug.Assert(Slider != null); + Debug.Assert(HitObject != null); - double completionProgress = Math.Clamp((Time.Current - Slider.StartTime) / Slider.Duration, 0, 1); + if (TrackFollowCircle) + { + double completionProgress = Math.Clamp((Time.Current - Slider.StartTime) / Slider.Duration, 0, 1); - //todo: we probably want to reconsider this before adding scoring, but it looks and feels nice. - if (!IsHit) - Position = Slider.CurvePositionAt(completionProgress); + //todo: we probably want to reconsider this before adding scoring, but it looks and feels nice. + if (!IsHit) + Position = Slider.CurvePositionAt(completionProgress); + } + } + + protected override HitResult ResultFor(double timeOffset) + { + Debug.Assert(HitObject != null); + + if (HitObject.JudgeAsNormalHitCircle) + return base.ResultFor(timeOffset); + + // If not judged as a normal hitcircle, judge as a slider tick instead. This is the classic osu!stable scoring. + var result = base.ResultFor(timeOffset); + return result.IsHit() ? HitResult.LargeTickHit : HitResult.LargeTickMiss; } public Action OnShake; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index c58f703bef..d02376b6c3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -130,7 +130,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { if (tracking.NewValue) { - spinningSample?.Play(!spinningSample.IsPlaying); + if (!spinningSample.IsPlaying) + spinningSample?.Play(); spinningSample?.VolumeTo(1, 300); } else diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 15af141c99..22b64af3df 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.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.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -25,6 +26,11 @@ namespace osu.Game.Rulesets.Osu.Objects /// internal const float BASE_SCORING_DISTANCE = 100; + /// + /// Minimum preempt time at AR=10. + /// + public const double PREEMPT_MIN = 450; + public double TimePreempt = 600; public double TimeFadeIn = 400; @@ -112,8 +118,13 @@ namespace osu.Game.Rulesets.Osu.Objects { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); - TimeFadeIn = 400; // as per osu-stable + TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, PREEMPT_MIN); + + // 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. + // Note that this doesn't exactly match the AR>10 visuals as they're classically known, but it feels good. + // This adjustment is necessary for AR>10, otherwise TimePreempt can become smaller leading to hitcircles not fully fading in. + TimeFadeIn = 400 * Math.Min(1, TimePreempt / PREEMPT_MIN); Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2; } diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 1670df24a8..e2b6c84896 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -114,8 +114,14 @@ namespace osu.Game.Rulesets.Osu.Objects /// public double TickDistanceMultiplier = 1; + /// + /// Whether this 's judgement is fully handled by its nested s. + /// If false, this will be judged proportionally to the number of nested s hit. + /// + public bool OnlyJudgeNestedObjects = true; + [JsonIgnore] - public HitCircle HeadCircle { get; protected set; } + public SliderHeadCircle HeadCircle { get; protected set; } [JsonIgnore] public SliderTailCircle TailCircle { get; protected set; } @@ -140,7 +146,8 @@ namespace osu.Game.Rulesets.Osu.Objects // The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to. // For now, the samples are attached to and played by the slider itself at the correct end time. - Samples = this.GetNodeSamples(repeatCount + 1); + // ToArray call is required as GetNodeSamples may fallback to Samples itself (without it it will get cleared due to the list reference being live). + Samples = this.GetNodeSamples(repeatCount + 1).ToArray(); } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) @@ -233,7 +240,7 @@ namespace osu.Game.Rulesets.Osu.Objects HeadCircle.Samples = this.GetNodeSamples(0); } - public override Judgement CreateJudgement() => new OsuIgnoreJudgement(); + public override Judgement CreateJudgement() => OnlyJudgeNestedObjects ? new OsuIgnoreJudgement() : new OsuJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs index f6d46aeef5..5672283230 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs @@ -1,9 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Judgements; + namespace osu.Game.Rulesets.Osu.Objects { public class SliderHeadCircle : HitCircle { + /// + /// Whether to treat this as a normal for judgement purposes. + /// If false, this will be judged as a instead. + /// + public bool JudgeAsNormalHitCircle = true; + + public override Judgement CreateJudgement() => JudgeAsNormalHitCircle ? base.CreateJudgement() : new SliderTickJudgement(); } } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index a427ee1955..725dbe81fb 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -33,10 +33,5 @@ namespace osu.Game.Rulesets.Osu.Objects protected override HitWindows CreateHitWindows() => HitWindows.Empty; public override Judgement CreateJudgement() => new SliderTickJudgement(); - - public class SliderTickJudgement : OsuJudgement - { - public override HitResult MaxResult => HitResult.LargeTickHit; - } } } diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index c8fe4f41ca..7314021a14 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu protected override bool Handle(UIEvent e) { - if (e is MouseMoveEvent && !AllowUserCursorMovement) return false; + if ((e is MouseMoveEvent || e is TouchMoveEvent) && !AllowUserCursorMovement) return false; return base.Handle(e); } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index cba0c5be14..18324a18a8 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -163,6 +163,7 @@ namespace osu.Game.Rulesets.Osu { new OsuModTarget(), new OsuModDifficultyAdjust(), + new OsuModClassic() }; case ModType.Automation: diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 954a217473..693943a08a 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -6,10 +6,12 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; using osu.Game.Replays; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Scoring; @@ -33,11 +35,6 @@ namespace osu.Game.Rulesets.Osu.Replays #region Constants - /// - /// The "reaction time" in ms between "seeing" a new hit object and moving to "react" to it. - /// - private readonly double reactionTime; - private readonly HitWindows defaultHitWindows; /// @@ -49,12 +46,9 @@ namespace osu.Game.Rulesets.Osu.Replays #region Construction / Initialisation - public OsuAutoGenerator(IBeatmap beatmap) - : base(beatmap) + public OsuAutoGenerator(IBeatmap beatmap, IReadOnlyList mods) + : base(beatmap, mods) { - // Already superhuman, but still somewhat realistic - reactionTime = ApplyModsToRate(100); - defaultHitWindows = new OsuHitWindows(); defaultHitWindows.SetDifficulty(Beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); } @@ -240,7 +234,7 @@ namespace osu.Game.Rulesets.Osu.Replays OsuReplayFrame lastFrame = (OsuReplayFrame)Frames[^1]; // Wait until Auto could "see and react" to the next note. - double waitTime = h.StartTime - Math.Max(0.0, h.TimePreempt - reactionTime); + double waitTime = h.StartTime - Math.Max(0.0, h.TimePreempt - getReactionTime(h.StartTime - h.TimePreempt)); if (waitTime > lastFrame.Time) { @@ -250,7 +244,7 @@ namespace osu.Game.Rulesets.Osu.Replays Vector2 lastPosition = lastFrame.Position; - double timeDifference = ApplyModsToTime(h.StartTime - lastFrame.Time); + double timeDifference = ApplyModsToTimeDelta(lastFrame.Time, h.StartTime); // Only "snap" to hitcircles if they are far enough apart. As the time between hitcircles gets shorter the snapping threshold goes up. if (timeDifference > 0 && // Sanity checks @@ -258,7 +252,7 @@ namespace osu.Game.Rulesets.Osu.Replays timeDifference >= 266)) // ... or the beats are slow enough to tap anyway. { // Perform eased movement - for (double time = lastFrame.Time + FrameDelay; time < h.StartTime; time += FrameDelay) + for (double time = lastFrame.Time + GetFrameDelay(lastFrame.Time); time < h.StartTime; time += GetFrameDelay(time)) { Vector2 currentPosition = Interpolation.ValueAt(time, lastPosition, targetPos, lastFrame.Time, h.StartTime, easing); AddFrameToReplay(new OsuReplayFrame((int)time, new Vector2(currentPosition.X, currentPosition.Y)) { Actions = lastFrame.Actions }); @@ -272,6 +266,14 @@ namespace osu.Game.Rulesets.Osu.Replays } } + /// + /// Calculates the "reaction time" in ms between "seeing" a new hit object and moving to "react" to it. + /// + /// + /// Already superhuman, but still somewhat realistic. + /// + private double getReactionTime(double timeInstant) => ApplyModsToRate(timeInstant, 100); + // Add frames to click the hitobject private void addHitObjectClickFrames(OsuHitObject h, Vector2 startPosition, float spinnerDirection) { @@ -341,17 +343,23 @@ namespace osu.Game.Rulesets.Osu.Replays float angle = radius == 0 ? 0 : MathF.Atan2(difference.Y, difference.X); double t; + double previousFrame = h.StartTime; - for (double j = h.StartTime + FrameDelay; j < spinner.EndTime; j += FrameDelay) + for (double nextFrame = h.StartTime + GetFrameDelay(h.StartTime); nextFrame < spinner.EndTime; nextFrame += GetFrameDelay(nextFrame)) { - t = ApplyModsToTime(j - h.StartTime) * spinnerDirection; + t = ApplyModsToTimeDelta(previousFrame, nextFrame) * spinnerDirection; + angle += (float)t / 20; - Vector2 pos = SPINNER_CENTRE + CirclePosition(t / 20 + angle, SPIN_RADIUS); - AddFrameToReplay(new OsuReplayFrame((int)j, new Vector2(pos.X, pos.Y), action)); + Vector2 pos = SPINNER_CENTRE + CirclePosition(angle, SPIN_RADIUS); + AddFrameToReplay(new OsuReplayFrame((int)nextFrame, new Vector2(pos.X, pos.Y), action)); + + previousFrame = nextFrame; } - t = ApplyModsToTime(spinner.EndTime - h.StartTime) * spinnerDirection; - Vector2 endPosition = SPINNER_CENTRE + CirclePosition(t / 20 + angle, SPIN_RADIUS); + t = ApplyModsToTimeDelta(previousFrame, spinner.EndTime) * spinnerDirection; + angle += (float)t / 20; + + Vector2 endPosition = SPINNER_CENTRE + CirclePosition(angle, SPIN_RADIUS); AddFrameToReplay(new OsuReplayFrame(spinner.EndTime, new Vector2(endPosition.X, endPosition.Y), action)); @@ -359,7 +367,7 @@ namespace osu.Game.Rulesets.Osu.Replays break; case Slider slider: - for (double j = FrameDelay; j < slider.Duration; j += FrameDelay) + for (double j = GetFrameDelay(slider.StartTime); j < slider.Duration; j += GetFrameDelay(slider.StartTime + j)) { Vector2 pos = slider.StackedPositionAt(j / slider.Duration); AddFrameToReplay(new OsuReplayFrame(h.StartTime + j, new Vector2(pos.X, pos.Y), action)); diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs index 3356a0fbe0..1cb3208c30 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs @@ -5,7 +5,9 @@ using osuTK; using osu.Game.Beatmaps; using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Replays; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays; @@ -22,33 +24,61 @@ namespace osu.Game.Rulesets.Osu.Replays public const float SPIN_RADIUS = 50; - /// - /// The time in ms between each ReplayFrame. - /// - protected readonly double FrameDelay; - #endregion #region Construction / Initialisation protected Replay Replay; protected List Frames => Replay.Frames; + private readonly IReadOnlyList timeAffectingMods; - protected OsuAutoGeneratorBase(IBeatmap beatmap) + protected OsuAutoGeneratorBase(IBeatmap beatmap, IReadOnlyList mods) : base(beatmap) { Replay = new Replay(); - // We are using ApplyModsToRate and not ApplyModsToTime to counteract the speed up / slow down from HalfTime / DoubleTime so that we remain at a constant framerate of 60 fps. - FrameDelay = ApplyModsToRate(1000.0 / 60.0); + timeAffectingMods = mods.OfType().ToList(); } #endregion #region Utilities - protected double ApplyModsToTime(double v) => v; - protected double ApplyModsToRate(double v) => v; + /// + /// Returns the real duration of time between and + /// after applying rate-affecting mods. + /// + /// + /// This method should only be used when and are very close. + /// That is because the track rate might be changing with time, + /// and the method used here is a rough instantaneous approximation. + /// + /// The start time of the time delta, in original track time. + /// The end time of the time delta, in original track time. + protected double ApplyModsToTimeDelta(double startTime, double endTime) + { + double delta = endTime - startTime; + + foreach (var mod in timeAffectingMods) + delta /= mod.ApplyToRate(startTime); + + return delta; + } + + protected double ApplyModsToRate(double time, double rate) + { + foreach (var mod in timeAffectingMods) + rate = mod.ApplyToRate(time, rate); + return rate; + } + + /// + /// Calculates the interval after which the next should be generated, + /// in milliseconds. + /// + /// The time of the previous frame. + protected double GetFrameDelay(double time) + => ApplyModsToRate(time, 1000.0 / 60); private class ReplayFrameComparer : IComparer { diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs index e77c93c721..4dd7b2d69c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs @@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default [Resolved(CanBeNull = true)] private OsuRulesetConfigManager config { get; set; } + private readonly Bindable configSnakingOut = new Bindable(); + [BackgroundDependencyLoader] private void load(ISkinSource skin, DrawableHitObject drawableObject) { @@ -36,10 +38,28 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default accentColour.BindValueChanged(accent => updateAccentColour(skin, accent.NewValue), true); config?.BindWith(OsuRulesetSetting.SnakingInSliders, SnakingIn); - config?.BindWith(OsuRulesetSetting.SnakingOutSliders, SnakingOut); + config?.BindWith(OsuRulesetSetting.SnakingOutSliders, configSnakingOut); + + SnakingOut.BindTo(configSnakingOut); BorderSize = skin.GetConfig(OsuSkinConfiguration.SliderBorderSize)?.Value ?? 1; BorderColour = skin.GetConfig(OsuSkinColour.SliderBorder)?.Value ?? Color4.White; + + drawableObject.HitObjectApplied += onHitObjectApplied; + } + + private void onHitObjectApplied(DrawableHitObject obj) + { + var drawableSlider = (DrawableSlider)obj; + if (drawableSlider.HitObject == null) + return; + + // When not tracking the follow circle, unbind from the config and forcefully disable snaking out - it looks better that way. + if (!drawableSlider.HeadCircle.TrackFollowCircle) + { + SnakingOut.UnbindFrom(configSnakingOut); + SnakingOut.Value = false; + } } private void updateAccentColour(ISkinSource skin, Color4 defaultAccentColour) diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs index a96beb66d4..82b677e12c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs @@ -31,6 +31,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default set => ball.Colour = value; } + /// + /// Whether to track accurately to the visual size of this . + /// If false, tracking will be performed at the final scale at all times. + /// + public bool InputTracksVisualSize = true; + private readonly Drawable followCircle; private readonly DrawableSlider drawableSlider; private readonly Drawable ball; @@ -94,7 +100,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default tracking = value; - followCircle.ScaleTo(tracking ? 2.4f : 1f, 300, Easing.OutQuint); + if (InputTracksVisualSize) + followCircle.ScaleTo(tracking ? 2.4f : 1f, 300, Easing.OutQuint); + else + { + // We need to always be tracking the final size, at both endpoints. For now, this is achieved by removing the scale duration. + followCircle.ScaleTo(tracking ? 2.4f : 1f); + } + followCircle.FadeTo(tracking ? 1f : 0, 300, Easing.OutQuint); } } diff --git a/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs new file mode 100644 index 0000000000..83f205deac --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs @@ -0,0 +1,54 @@ +// 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.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.UI +{ + /// + /// Ensures that s are hit in order of appearance. The classic note lock. + /// + /// Hits will be blocked until the previous s have been judged. + /// + /// + public class ObjectOrderedHitPolicy : IHitPolicy + { + public IHitObjectContainer HitObjectContainer { get; set; } + + public bool IsHittable(DrawableHitObject hitObject, double time) => enumerateHitObjectsUpTo(hitObject.HitObject.StartTime).All(obj => obj.AllJudged); + + public void HandleHit(DrawableHitObject hitObject) + { + } + + private IEnumerable enumerateHitObjectsUpTo(double targetTime) + { + foreach (var obj in HitObjectContainer.AliveObjects) + { + if (obj.HitObject.StartTime >= targetTime) + yield break; + + switch (obj) + { + case DrawableSpinner _: + continue; + + case DrawableSlider slider: + yield return slider.HeadCircle; + + break; + + default: + yield return obj; + + break; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index e085714265..b1069149f3 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -31,7 +31,6 @@ namespace osu.Game.Rulesets.Osu.UI private readonly ProxyContainer spinnerProxies; private readonly JudgementContainer judgementLayer; private readonly FollowPointRenderer followPoints; - private readonly StartTimeOrderedHitPolicy hitPolicy; public static readonly Vector2 BASE_SIZE = new Vector2(512, 384); @@ -54,10 +53,9 @@ namespace osu.Game.Rulesets.Osu.UI approachCircles = new ProxyContainer { RelativeSizeAxes = Axes.Both }, }; - hitPolicy = new StartTimeOrderedHitPolicy { HitObjectContainer = HitObjectContainer }; + HitPolicy = new StartTimeOrderedHitPolicy(); var hitWindows = new OsuHitWindows(); - foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) poolDictionary.Add(result, new DrawableJudgementPool(result, onJudgmentLoaded)); @@ -66,6 +64,18 @@ namespace osu.Game.Rulesets.Osu.UI NewResult += onNewResult; } + private IHitPolicy hitPolicy; + + public IHitPolicy HitPolicy + { + get => hitPolicy; + set + { + hitPolicy = value ?? throw new ArgumentNullException(nameof(value)); + hitPolicy.HitObjectContainer = HitObjectContainer; + } + } + protected override void OnNewDrawableHitObject(DrawableHitObject drawable) { ((DrawableOsuHitObject)drawable).CheckHittable = hitPolicy.IsHittable; 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 2a5a2e2fdb..2b084f3bee 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs index 5b890b3d03..64e59b64d0 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModAutoplay : ModAutoplay { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "mekkadosu!" } }, Replay = new TaikoAutoGenerator(beatmap).Generate(), diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs index 71aa007d3b..00f0c8e321 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModCinema : ModCinema { - public override Score CreateReplayScore(IBeatmap beatmap) => new Score + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { ScoreInfo = new ScoreInfo { User = new User { Username = "mekkadosu!" } }, Replay = new TaikoAutoGenerator(beatmap).Generate(), diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index d8e3100048..9f29675230 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -152,7 +152,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy throw new ArgumentOutOfRangeException(nameof(component), $"Invalid component type: {component}"); } - public override SampleChannel GetSample(ISampleInfo sampleInfo) => Source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); + public override Sample GetSample(ISampleInfo sampleInfo) => Source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); public override IBindable GetConfig(TLookup lookup) => Source.GetConfig(lookup); diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index 600c820ce1..b80da928c8 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -21,6 +21,27 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(36, result.Links[0].Length); } + [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123#osu/456")] + [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123#osu/456?whatever")] + [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123/456")] + [TestCase(LinkAction.External, null, "https://dev.ppy.sh/beatmapsets/abc/def")] + [TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123")] + [TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123/whatever")] + [TestCase(LinkAction.External, null, "https://dev.ppy.sh/beatmapsets/abc")] + public void TestBeatmapLinks(LinkAction expectedAction, string expectedArg, string link) + { + MessageFormatter.WebsiteRootUrl = "dev.ppy.sh"; + + 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); + if (expectedAction == LinkAction.External) + Assert.AreEqual(link, result.Links[0].Url); + } + [Test] public void TestMultipleComplexLinks() { diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index de46f9d1cf..3ded3009bd 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -121,7 +121,7 @@ namespace osu.Game.Tests.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) { diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index 38cb6729c3..7a0dd5b719 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -35,7 +35,7 @@ namespace osu.Game.Tests.Gameplay public void TestRetrieveTopLevelSample() { ISkin skin = null; - SampleChannel channel = null; + Sample channel = null; AddStep("create skin", () => skin = new TestSkin("test-sample", this)); AddStep("retrieve sample", () => channel = skin.GetSample(new SampleInfo("test-sample"))); @@ -47,7 +47,7 @@ namespace osu.Game.Tests.Gameplay public void TestRetrieveSampleInSubFolder() { ISkin skin = null; - SampleChannel channel = null; + Sample channel = null; AddStep("create skin", () => skin = new TestSkin("folder/test-sample", this)); AddStep("retrieve sample", () => channel = skin.GetSample(new SampleInfo("folder/test-sample"))); diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs index a5c937119e..da004b9088 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.NonVisual.Skinning } public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotSupportedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotSupportedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotSupportedException(); public IBindable GetConfig(TLookup lookup) => throw new NotSupportedException(); } diff --git a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index aa6f66da81..ab24a72a12 100644 --- a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -68,12 +68,29 @@ namespace osu.Game.Tests.Online Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25)); } + [Test] + public void TestDeserialiseDifficultyAdjustModWithExtendedLimits() + { + var apiMod = new APIMod(new TestModDifficultyAdjust + { + OverallDifficulty = { Value = 11 }, + ExtendedLimits = { Value = true } + }); + + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + var converted = (TestModDifficultyAdjust)deserialised.ToMod(new TestRuleset()); + + Assert.That(converted.ExtendedLimits.Value, Is.True); + Assert.That(converted.OverallDifficulty.Value, Is.EqualTo(11)); + } + private class TestRuleset : Ruleset { public override IEnumerable GetModsFor(ModType type) => new Mod[] { new TestMod(), new TestModTimeRamp(), + new TestModDifficultyAdjust() }; public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); @@ -135,5 +152,9 @@ namespace osu.Game.Tests.Online Value = true }; } + + private class TestModDifficultyAdjust : ModDifficultyAdjust + { + } } } diff --git a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs index 4294f89397..74db477cfc 100644 --- a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs @@ -68,6 +68,16 @@ namespace osu.Game.Tests.Online Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25)); } + [Test] + public void TestDeserialiseEnumMod() + { + var apiMod = new APIMod(new TestModEnum { TestSetting = { Value = TestEnum.Value2 } }); + + var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod)); + + Assert.That(deserialized.Settings, Contains.Key("test_setting").With.ContainValue(1)); + } + private class TestRuleset : Ruleset { public override IEnumerable GetModsFor(ModType type) => new Mod[] @@ -135,5 +145,22 @@ namespace osu.Game.Tests.Online Value = true }; } + + private class TestModEnum : Mod + { + public override string Name => "Test Mod"; + public override string Acronym => "TM"; + public override double ScoreMultiplier => 1; + + [SettingSource("Test")] + public Bindable TestSetting { get; } = new Bindable(); + } + + private enum TestEnum + { + Value1 = 0, + Value2 = 1, + Value3 = 2 + } } } diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index d3475de157..3ffb512b7f 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -165,10 +165,10 @@ namespace osu.Game.Tests.Online { } - public override async Task Import(BeatmapSetInfo item, ArchiveReader archive = null, CancellationToken cancellationToken = default) + public override async Task Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) { await AllowImport.Task; - return await (CurrentImportTask = base.Import(item, archive, cancellationToken)); + return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)); } } diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs index 987a5812db..787f72ba79 100644 --- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -105,9 +105,9 @@ namespace osu.Game.Tests.Rulesets IsDisposed = true; } - public SampleChannel Get(string name) => null; + public Sample Get(string name) => null; - public Task GetAsync(string name) => null; + public Task GetAsync(string name) => null; public Stream GetStream(string name) => null; diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index ad5b3ec0f6..414f7d3f88 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs @@ -219,7 +219,7 @@ namespace osu.Game.Tests.Skins public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => skin.GetTexture(componentName, wrapModeS, wrapModeT); - public SampleChannel GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); + public Sample GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => skin.GetConfig(lookup); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs index f182023c0e..2abc8a8dec 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs @@ -3,11 +3,11 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Graphics.Audio; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Editing { @@ -19,14 +19,14 @@ namespace osu.Game.Tests.Visual.Editing public void TestSlidingSampleStopsOnSeek() { DrawableSlider slider = null; - DrawableSample[] loopingSamples = null; - DrawableSample[] onceOffSamples = null; + PoolableSkinnableSample[] loopingSamples = null; + PoolableSkinnableSample[] onceOffSamples = null; AddStep("get first slider", () => { slider = Editor.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).First(); - onceOffSamples = slider.ChildrenOfType().Where(s => !s.Looping).ToArray(); - loopingSamples = slider.ChildrenOfType().Where(s => s.Looping).ToArray(); + onceOffSamples = slider.ChildrenOfType().Where(s => !s.Looping).ToArray(); + loopingSamples = slider.ChildrenOfType().Where(s => s.Looping).ToArray(); }); AddStep("start playback", () => EditorClock.Start()); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs index 7c6a213fe2..6b3fc304e0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Graphics.Audio; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; @@ -20,14 +19,14 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestAllSamplesStopDuringSeek() { DrawableSlider slider = null; - DrawableSample[] samples = null; + PoolableSkinnableSample[] samples = null; ISamplePlaybackDisabler sampleDisabler = null; AddUntilStep("get variables", () => { sampleDisabler = Player; slider = Player.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).FirstOrDefault(); - samples = slider?.ChildrenOfType().ToArray(); + samples = slider?.ChildrenOfType().ToArray(); return slider != null; }); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs index 3a71d4ca54..f94e122b30 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Gameplay { var beatmap = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, Array.Empty()); - return new ScoreAccessibleReplayPlayer(ruleset.GetAutoplayMod()?.CreateReplayScore(beatmap)); + return new ScoreAccessibleReplayPlayer(ruleset.GetAutoplayMod()?.CreateReplayScore(beatmap, Array.Empty())); } protected override void AddCheckSteps() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs index bed48f3d86..44142b69d7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs @@ -298,7 +298,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); } @@ -309,7 +309,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); } @@ -321,7 +321,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs index fc0cda2c1f..d688e9cb21 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -43,70 +43,60 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestStoppedSoundDoesntResumeAfterPause() { - DrawableSample sample = null; AddStep("start sample with looping", () => { - sample = skinnableSound.ChildrenOfType().First(); - skinnableSound.Looping = true; skinnableSound.Play(); }); - AddUntilStep("wait for sample to start playing", () => sample.Playing); + AddUntilStep("wait for sample to start playing", () => skinnableSound.IsPlaying); AddStep("stop sample", () => skinnableSound.Stop()); - AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + AddUntilStep("wait for sample to stop playing", () => !skinnableSound.IsPlaying); AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); AddWaitStep("wait a bit", 5); - AddAssert("sample not playing", () => !sample.Playing); + AddAssert("sample not playing", () => !skinnableSound.IsPlaying); } [Test] public void TestLoopingSoundResumesAfterPause() { - DrawableSample sample = null; AddStep("start sample with looping", () => { skinnableSound.Looping = true; skinnableSound.Play(); - sample = skinnableSound.ChildrenOfType().First(); }); - AddUntilStep("wait for sample to start playing", () => sample.Playing); + AddUntilStep("wait for sample to start playing", () => skinnableSound.IsPlaying); AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); - AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + AddUntilStep("wait for sample to stop playing", () => !skinnableSound.IsPlaying); AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); - AddUntilStep("wait for sample to start playing", () => sample.Playing); + AddUntilStep("wait for sample to start playing", () => skinnableSound.IsPlaying); } [Test] public void TestNonLoopingStopsWithPause() { - DrawableSample sample = null; - AddStep("start sample", () => - { - skinnableSound.Play(); - sample = skinnableSound.ChildrenOfType().First(); - }); + AddStep("start sample", () => skinnableSound.Play()); - AddAssert("sample playing", () => sample.Playing); + AddAssert("sample playing", () => skinnableSound.IsPlaying); AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); - AddUntilStep("sample not playing", () => !sample.Playing); + AddUntilStep("sample not playing", () => !skinnableSound.IsPlaying); AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); - AddAssert("sample not playing", () => !sample.Playing); - AddAssert("sample not playing", () => !sample.Playing); - AddAssert("sample not playing", () => !sample.Playing); + AddAssert("sample not playing", () => !skinnableSound.IsPlaying); + AddAssert("sample not playing", () => !skinnableSound.IsPlaying); + AddAssert("sample not playing", () => !skinnableSound.IsPlaying); } [Test] @@ -119,10 +109,10 @@ namespace osu.Game.Tests.Visual.Gameplay sample = skinnableSound.ChildrenOfType().Single(); }); - AddAssert("sample playing", () => sample.Playing); + AddAssert("sample playing", () => skinnableSound.IsPlaying); AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); - AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + AddUntilStep("wait for sample to stop playing", () => !skinnableSound.IsPlaying); AddStep("trigger skin change", () => skinSource.TriggerSourceChanged()); @@ -133,11 +123,11 @@ namespace osu.Game.Tests.Visual.Gameplay return sample != oldSample; }); - AddAssert("new sample stopped", () => !sample.Playing); + AddAssert("new sample stopped", () => !skinnableSound.IsPlaying); AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); AddWaitStep("wait a bit", 5); - AddAssert("new sample not played", () => !sample.Playing); + AddAssert("new sample not played", () => !skinnableSound.IsPlaying); } [Cached(typeof(ISkinSource))] @@ -155,7 +145,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Drawable GetDrawableComponent(ISkinComponent component) => source?.GetDrawableComponent(component); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => source?.GetTexture(componentName, wrapModeS, wrapModeT); - public SampleChannel GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); + public Sample GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => source?.GetConfig(lookup); public void TriggerSourceChanged() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 26524f07da..4a0e1282c4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -244,11 +243,6 @@ namespace osu.Game.Tests.Visual.Gameplay { } - protected override Task Connect() - { - return Task.CompletedTask; - } - public void StartPlay(int beatmapId) { this.beatmapId = beatmapId; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index 279dcfa584..5682fd5c3c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -69,6 +69,20 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("last room joined", () => RoomManager.Rooms.Last().Status.Value is JoinedRoomStatus); } + [Test] + public void TestClickDeselection() + { + AddRooms(1); + + AddAssert("no selection", () => checkRoomSelected(null)); + + press(Key.Down); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + + AddStep("click away", () => InputManager.Click(MouseButton.Left)); + AddAssert("no selection", () => checkRoomSelected(null)); + } + private void press(Key down) { AddStep($"press {down}", () => InputManager.Key(down)); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index d016accc25..aab69d687a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -163,8 +162,6 @@ namespace osu.Game.Tests.Visual.Multiplayer ((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, Array.Empty())); } } - - protected override Task Connect() => Task.CompletedTask; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index e2c98c0aad..0f7a9b442d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -7,7 +7,10 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; @@ -19,16 +22,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public class TestSceneMultiplayerParticipantsList : MultiplayerTestScene { [SetUp] - public new void Setup() => Schedule(() => - { - Child = new ParticipantsList - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Size = new Vector2(380, 0.7f) - }; - }); + public new void Setup() => Schedule(createNewParticipantsList); [Test] public void TestAddUser() @@ -75,6 +69,50 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("single panel is for second user", () => this.ChildrenOfType().Single().User.User == secondUser); } + [Test] + public void TestGameStateHasPriorityOverDownloadState() + { + AddStep("set to downloading map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); + checkProgressBarVisibility(true); + + AddStep("make user ready", () => Client.ChangeState(MultiplayerUserState.Results)); + checkProgressBarVisibility(false); + AddUntilStep("ready mark visible", () => this.ChildrenOfType().Single().IsPresent); + + AddStep("make user ready", () => Client.ChangeState(MultiplayerUserState.Idle)); + checkProgressBarVisibility(true); + } + + [Test] + public void TestCorrectInitialState() + { + AddStep("set to downloading map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); + AddStep("recreate list", createNewParticipantsList); + checkProgressBarVisibility(true); + } + + [Test] + public void TestBeatmapDownloadingStates() + { + AddStep("set to no map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.NotDownloaded())); + AddStep("set to downloading map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); + + checkProgressBarVisibility(true); + + AddRepeatStep("increment progress", () => + { + var progress = this.ChildrenOfType().Single().User.BeatmapAvailability.DownloadProgress ?? 0; + Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(progress + RNG.NextSingle(0.1f))); + }, 25); + + AddAssert("progress bar increased", () => this.ChildrenOfType().Single().Current.Value > 0); + + AddStep("set to importing map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Importing())); + checkProgressBarVisibility(false); + + AddStep("set to available", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.LocallyAvailable())); + } + [Test] public void TestToggleReadyState() { @@ -122,6 +160,26 @@ namespace osu.Game.Tests.Visual.Multiplayer }); Client.ChangeUserState(i, (MultiplayerUserState)RNG.Next(0, (int)MultiplayerUserState.Results + 1)); + + if (RNG.NextBool()) + { + var beatmapState = (DownloadState)RNG.Next(0, (int)DownloadState.LocallyAvailable + 1); + + switch (beatmapState) + { + case DownloadState.NotDownloaded: + Client.ChangeUserBeatmapAvailability(i, BeatmapAvailability.NotDownloaded()); + break; + + case DownloadState.Downloading: + Client.ChangeUserBeatmapAvailability(i, BeatmapAvailability.Downloading(RNG.NextSingle())); + break; + + case DownloadState.Importing: + Client.ChangeUserBeatmapAvailability(i, BeatmapAvailability.Importing()); + break; + } + } } }); } @@ -152,5 +210,14 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep($"set state: {state}", () => Client.ChangeUserState(0, state)); } } + + private void createNewParticipantsList() + { + Child = new ParticipantsList { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Y, Size = new Vector2(380, 0.7f) }; + } + + private void checkProgressBarVisibility(bool visible) => + AddUntilStep($"progress bar {(visible ? "is" : "is not")}visible", () => + this.ChildrenOfType().Single().IsPresent == visible); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index 1d13c6229c..7d83ba569d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -87,6 +87,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Ruleset.Value = new OsuRuleset().RulesetInfo; Beatmap.SetDefault(); + SelectedMods.Value = Array.Empty(); }); AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect())); @@ -143,7 +144,6 @@ namespace osu.Game.Tests.Visual.Multiplayer /// Tests that the same instances are not shared between two playlist items. /// [Test] - [Ignore("Temporarily disabled due to a non-trivial test failure")] public void TestNewItemHasNewModInstances() { AddStep("set dt mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index 9e69530a77..74f53ebdca 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -103,26 +103,26 @@ namespace osu.Game.Tests.Visual.Online private void testLinksGeneral() { addMessageWithChecks("test!"); - addMessageWithChecks("osu.ppy.sh!"); - addMessageWithChecks("https://osu.ppy.sh!", 1, expectedActions: LinkAction.External); + addMessageWithChecks("dev.ppy.sh!"); + addMessageWithChecks("https://dev.ppy.sh!", 1, expectedActions: LinkAction.External); addMessageWithChecks("00:12:345 (1,2) - Test?", 1, expectedActions: LinkAction.OpenEditorTimestamp); addMessageWithChecks("Wiki link for tasty [[Performance Points]]", 1, expectedActions: LinkAction.External); - addMessageWithChecks("(osu forums)[https://osu.ppy.sh/forum] (old link format)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[https://osu.ppy.sh/home New site] (new link format)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[osu forums](https://osu.ppy.sh/forum) (new link format 2)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[https://osu.ppy.sh/home This is only a link to the new osu webpage but this is supposed to test word wrap.]", 1, expectedActions: LinkAction.External); - addMessageWithChecks("is now listening to [https://osu.ppy.sh/s/93523 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmapSet); - addMessageWithChecks("is now playing [https://osu.ppy.sh/b/252238 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmap); - addMessageWithChecks("Let's (try)[https://osu.ppy.sh/home] [https://osu.ppy.sh/b/252238 multiple links] https://osu.ppy.sh/home", 3, + addMessageWithChecks("(osu forums)[https://dev.ppy.sh/forum] (old link format)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[https://dev.ppy.sh/home New site] (new link format)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[osu forums](https://dev.ppy.sh/forum) (new link format 2)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[https://dev.ppy.sh/home This is only a link to the new osu webpage but this is supposed to test word wrap.]", 1, expectedActions: LinkAction.External); + addMessageWithChecks("is now listening to [https://dev.ppy.sh/s/93523 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmapSet); + addMessageWithChecks("is now playing [https://dev.ppy.sh/b/252238 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmap); + addMessageWithChecks("Let's (try)[https://dev.ppy.sh/home] [https://dev.ppy.sh/b/252238 multiple links] https://dev.ppy.sh/home", 3, expectedActions: new[] { LinkAction.External, LinkAction.OpenBeatmap, LinkAction.External }); - addMessageWithChecks("[https://osu.ppy.sh/home New link format with escaped [and \\[ paired] braces]", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[Markdown link format with escaped [and \\[ paired] braces](https://osu.ppy.sh/home)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("(Old link format with escaped (and \\( paired) parentheses)[https://osu.ppy.sh/home] and [[also a rogue wiki link]]", 2, expectedActions: new[] { LinkAction.External, LinkAction.External }); + addMessageWithChecks("[https://dev.ppy.sh/home New link format with escaped [and \\[ paired] braces]", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[Markdown link format with escaped [and \\[ paired] braces](https://dev.ppy.sh/home)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("(Old link format with escaped (and \\( paired) parentheses)[https://dev.ppy.sh/home] and [[also a rogue wiki link]]", 2, expectedActions: new[] { LinkAction.External, LinkAction.External }); // note that there's 0 links here (they get removed if a channel is not found) addMessageWithChecks("#lobby or #osu would be blue (and work) in the ChatDisplay test (when a proper ChatOverlay is present)."); addMessageWithChecks("I am important!", 0, false, true); addMessageWithChecks("feels important", 0, true, true); - addMessageWithChecks("likes to post this [https://osu.ppy.sh/home link].", 1, true, true, expectedActions: LinkAction.External); + addMessageWithChecks("likes to post this [https://dev.ppy.sh/home link].", 1, true, true, expectedActions: LinkAction.External); addMessageWithChecks("Join my multiplayer game osump://12346.", 1, expectedActions: LinkAction.JoinMultiplayerMatch); addMessageWithChecks("Join my [multiplayer game](osump://12346).", 1, expectedActions: LinkAction.JoinMultiplayerMatch); addMessageWithChecks("Join my [#english](osu://chan/#english).", 1, expectedActions: LinkAction.OpenChannel); @@ -136,9 +136,9 @@ namespace osu.Game.Tests.Visual.Online int echoCounter = 0; addEchoWithWait("sent!", "received!"); - addEchoWithWait("https://osu.ppy.sh/home", null, 500); - addEchoWithWait("[https://osu.ppy.sh/forum let's try multiple words too!]"); - addEchoWithWait("(long loading times! clickable while loading?)[https://osu.ppy.sh/home]", null, 5000); + addEchoWithWait("https://dev.ppy.sh/home", null, 500); + addEchoWithWait("[https://dev.ppy.sh/forum let's try multiple words too!]"); + addEchoWithWait("(long loading times! clickable while loading?)[https://dev.ppy.sh/home]", null, 5000); void addEchoWithWait(string text, string completeText = null, double delay = 250) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs index 8f20bcdcc1..dc468bb62d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online private class TestFullscreenOverlay : FullscreenOverlay { public TestFullscreenOverlay() - : base(OverlayColourScheme.Pink, null) + : base(OverlayColourScheme.Pink) { Children = new Drawable[] { @@ -52,6 +52,17 @@ namespace osu.Game.Tests.Visual.Online }, }; } + + protected override OverlayHeader CreateHeader() => new TestHeader(); + + internal class TestHeader : OverlayHeader + { + protected override OverlayTitle CreateTitle() => new TestTitle(); + + internal class TestTitle : OverlayTitle + { + } + } } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs index 626f545b91..aff510dd95 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Online Add(rankingsOverlay = new TestRankingsOverlay { Country = { BindTarget = countryBindable }, - Scope = { BindTarget = scope }, + Header = { Current = { BindTarget = scope } }, }); } @@ -65,8 +65,6 @@ namespace osu.Game.Tests.Visual.Online private class TestRankingsOverlay : RankingsOverlay { public new Bindable Country => base.Country; - - public new Bindable Scope => base.Scope; } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 37ebc72984..2885dbee00 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -39,7 +39,11 @@ namespace osu.Game.Tests.Visual.UserInterface } [SetUp] - public void SetUp() => Schedule(() => createDisplay(() => new TestModSelectOverlay())); + public void SetUp() => Schedule(() => + { + SelectedMods.Value = Array.Empty(); + createDisplay(() => new TestModSelectOverlay()); + }); [SetUpSteps] public void SetUpSteps() @@ -47,6 +51,24 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("show", () => modSelect.Show()); } + [Test] + public void TestSettingsResetOnDeselection() + { + var osuModDoubleTime = new OsuModDoubleTime { SpeedChange = { Value = 1.2 } }; + + changeRuleset(0); + + AddStep("set dt mod with custom rate", () => { SelectedMods.Value = new[] { osuModDoubleTime }; }); + + AddAssert("selected mod matches", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.Value == 1.2); + + AddStep("deselect", () => modSelect.DeselectAllButton.Click()); + AddAssert("selected mods empty", () => SelectedMods.Value.Count == 0); + + AddStep("reselect", () => modSelect.GetModButton(osuModDoubleTime).Click()); + AddAssert("selected mod has default value", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.IsDefault == true); + } + [Test] public void TestAnimationFlushOnClose() { @@ -152,6 +174,45 @@ namespace osu.Game.Tests.Visual.UserInterface }); } + [Test] + public void TestSettingsAreRetainedOnReload() + { + changeRuleset(0); + + 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("create overlay", () => createDisplay(() => new TestNonStackedModSelectOverlay())); + + AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01); + } + + [Test] + public void TestExternallySetModIsReplacedByOverlayInstance() + { + Mod external = new OsuModDoubleTime(); + Mod overlayButtonMod = null; + + changeRuleset(0); + + AddStep("set mod externally", () => { SelectedMods.Value = new[] { external }; }); + + AddAssert("ensure button is selected", () => + { + var button = modSelect.GetModButton(SelectedMods.Value.Single()); + overlayButtonMod = button.SelectedMod; + return overlayButtonMod.GetType() == external.GetType(); + }); + + // Right now, when an external change occurs, the ModSelectOverlay will replace the global instance with its own + AddAssert("mod instance doesn't match", () => external != overlayButtonMod); + + AddAssert("one mod present in global selected", () => SelectedMods.Value.Count == 1); + AddAssert("globally selected matches button's mod instance", () => SelectedMods.Value.Contains(overlayButtonMod)); + AddAssert("globally selected doesn't contain original external change", () => !SelectedMods.Value.Contains(external)); + } + [Test] public void TestNonStacked() { @@ -313,7 +374,6 @@ namespace osu.Game.Tests.Visual.UserInterface private void createDisplay(Func createOverlayFunc) { - SelectedMods.Value = Array.Empty(); Children = new Drawable[] { modSelect = createOverlayFunc().With(d => diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index 43ba23e6c6..d0f6f3fe47 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -105,6 +105,15 @@ namespace osu.Game.Tests.Visual.UserInterface checkDisplayedCount(3); } + [Test] + public void TestError() + { + setState(Visibility.Visible); + AddStep(@"error #1", sendErrorNotification); + AddAssert("Is visible", () => notificationOverlay.State.Value == Visibility.Visible); + checkDisplayedCount(1); + } + [Test] public void TestSpam() { @@ -179,7 +188,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void sendBarrage() { - switch (RNG.Next(0, 4)) + switch (RNG.Next(0, 5)) { case 0: sendHelloNotification(); @@ -196,6 +205,10 @@ namespace osu.Game.Tests.Visual.UserInterface case 3: sendDownloadProgress(); break; + + case 4: + sendErrorNotification(); + break; } } @@ -214,6 +227,11 @@ namespace osu.Game.Tests.Visual.UserInterface notificationOverlay.Post(new BackgroundNotification { Text = @"Welcome to osu!. Enjoy your stay!" }); } + private void sendErrorNotification() + { + notificationOverlay.Post(new SimpleErrorNotification { Text = @"Rut roh!. Something went wrong!" }); + } + private void sendManyNotifications() { for (int i = 0; i < 10; i++) diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index d29ed94b5f..7e3868bd3b 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Tournament.Tests/TournamentTestBrowser.cs b/osu.Game.Tournament.Tests/TournamentTestBrowser.cs index f7ad757926..50bdcd86c5 100644 --- a/osu.Game.Tournament.Tests/TournamentTestBrowser.cs +++ b/osu.Game.Tournament.Tests/TournamentTestBrowser.cs @@ -13,15 +13,18 @@ namespace osu.Game.Tournament.Tests { base.LoadComplete(); - LoadComponentAsync(new Background("Menu/menu-background-0") + BracketLoadTask.ContinueWith(_ => Schedule(() => { - Colour = OsuColour.Gray(0.5f), - Depth = 10 - }, AddInternal); + LoadComponentAsync(new Background("Menu/menu-background-0") + { + Colour = OsuColour.Gray(0.5f), + Depth = 10 + }, AddInternal); - // Have to construct this here, rather than in the constructor, because - // we depend on some dependencies to be loaded within OsuGameBase.load(). - Add(new TestBrowser()); + // Have to construct this here, rather than in the constructor, because + // we depend on some dependencies to be loaded within OsuGameBase.load(). + Add(new TestBrowser()); + })); } } } diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs index d22da25f9d..47d2160561 100644 --- a/osu.Game.Tournament.Tests/TournamentTestScene.cs +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -2,6 +2,7 @@ // 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.Platform; using osu.Framework.Testing; @@ -154,13 +155,22 @@ namespace osu.Game.Tournament.Tests protected override void LoadAsyncComplete() { - // this has to be run here rather than LoadComplete because - // TestScene.cs is checking the IsLoaded state (on another thread) and expects - // the runner to be loaded at that point. - Add(runner = new TestSceneTestRunner.TestRunner()); + BracketLoadTask.ContinueWith(_ => Schedule(() => + { + // this has to be run here rather than LoadComplete because + // TestScene.cs is checking the IsLoaded state (on another thread) and expects + // the runner to be loaded at that point. + Add(runner = new TestSceneTestRunner.TestRunner()); + })); } - public void RunTestBlocking(TestScene test) => runner.RunTestBlocking(test); + public void RunTestBlocking(TestScene test) + { + while (runner?.IsLoaded != true && Host.ExecutionState == ExecutionState.Running) + Thread.Sleep(10); + + runner?.RunTestBlocking(test); + } } } } diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 185b35e40d..77ae06d89c 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index bbe4a53d8f..fadb821bef 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Colour; using osu.Game.Graphics.Cursor; using osu.Game.Tournament.Models; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; @@ -32,25 +33,24 @@ namespace osu.Game.Tournament private Drawable heightWarning; private Bindable windowSize; private Bindable windowMode; + private LoadingSpinner loadingSpinner; [BackgroundDependencyLoader] private void load(FrameworkConfigManager frameworkConfig) { windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); - windowSize.BindValueChanged(size => ScheduleAfterChildren(() => - { - var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1; - - heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; - }), true); - windowMode = frameworkConfig.GetBindable(FrameworkSetting.WindowMode); - windowMode.BindValueChanged(mode => ScheduleAfterChildren(() => - { - windowMode.Value = WindowMode.Windowed; - }), true); - AddRange(new[] + Add(loadingSpinner = new LoadingSpinner(true, true) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(40), + }); + + loadingSpinner.Show(); + + BracketLoadTask.ContinueWith(_ => LoadComponentsAsync(new[] { new Container { @@ -93,7 +93,24 @@ namespace osu.Game.Tournament RelativeSizeAxes = Axes.Both, Child = new TournamentSceneManager() } - }); + }, drawables => + { + loadingSpinner.Hide(); + loadingSpinner.Expire(); + + AddRange(drawables); + + windowSize.BindValueChanged(size => ScheduleAfterChildren(() => + { + var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1; + heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; + }), true); + + windowMode.BindValueChanged(mode => ScheduleAfterChildren(() => + { + windowMode.Value = WindowMode.Windowed; + }), true); + })); } } } diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 97c950261b..ffda101ee0 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.Linq; +using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Graphics.Textures; @@ -29,6 +30,10 @@ namespace osu.Game.Tournament private DependencyContainer dependencies; private FileBasedIPC ipc; + protected Task BracketLoadTask => taskCompletionSource.Task; + + private readonly TaskCompletionSource taskCompletionSource = new TaskCompletionSource(); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { return dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -46,14 +51,9 @@ namespace osu.Game.Tournament Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage))); - readBracket(); - - ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value); - dependencies.CacheAs(new StableInfo(storage)); - dependencies.CacheAs(ipc = new FileBasedIPC()); - Add(ipc); + Task.Run(readBracket); } private void readBracket() @@ -68,10 +68,6 @@ namespace osu.Game.Tournament ladder ??= new LadderInfo(); ladder.Ruleset.Value ??= RulesetStore.AvailableRulesets.First(); - Ruleset.BindTo(ladder.Ruleset); - - dependencies.Cache(ladder); - bool addedInfo = false; // assign teams @@ -127,6 +123,19 @@ namespace osu.Game.Tournament if (addedInfo) SaveChanges(); + + ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value); + + Schedule(() => + { + Ruleset.BindTo(ladder.Ruleset); + + dependencies.Cache(ladder); + dependencies.CacheAs(ipc = new FileBasedIPC()); + Add(ipc); + + taskCompletionSource.SetResult(true); + }); } /// @@ -143,7 +152,7 @@ namespace osu.Game.Tournament { if (string.IsNullOrEmpty(p.Username) || p.Statistics == null) { - PopulateUser(p); + PopulateUser(p, immediate: true); addedInfo = true; } } @@ -202,12 +211,14 @@ namespace osu.Game.Tournament return addedInfo; } - public void PopulateUser(User user, Action success = null, Action failure = null) + public void PopulateUser(User user, Action success = null, Action failure = null, bool immediate = false) { var req = new GetUserRequest(user.Id, Ruleset.Value); req.Success += res => { + user.Id = res.Id; + user.Username = res.Username; user.Statistics = res.Statistics; user.Country = res.Country; @@ -222,7 +233,10 @@ namespace osu.Game.Tournament failure?.Invoke(); }; - API.Queue(req); + if (immediate) + API.Perform(req); + else + API.Queue(req); } protected override void LoadComplete() diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index 8d02af6574..d88fd1e62b 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -27,6 +27,8 @@ namespace osu.Game.Audio protected TrackManagerPreviewTrack CurrentTrack; + private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(OsuGameBase.GLOBAL_TRACK_VOLUME_ADJUST); + [BackgroundDependencyLoader] private void load() { @@ -35,6 +37,7 @@ namespace osu.Game.Audio trackStore = new PreviewTrackStore(new OnlineStore()); audio.AddItem(trackStore); + trackStore.AddAdjustment(AdjustableProperty.Volume, globalTrackVolumeAdjust); trackStore.AddAdjustment(AdjustableProperty.Volume, audio.VolumeTrack); } diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index b934ac556d..3c6a6ba302 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -64,7 +64,9 @@ namespace osu.Game.Beatmaps protected override string[] HashableFileTypes => new[] { ".osu" }; - protected override string ImportFromStablePath => "Songs"; + protected override string ImportFromStablePath => "."; + + protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage(); private readonly RulesetStore rulesets; private readonly BeatmapStore beatmaps; diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index 39b3c23ddd..367f612dc8 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -51,7 +51,12 @@ namespace osu.Game.Beatmaps [JsonProperty(@"tags")] public string Tags { get; set; } + /// + /// The time in milliseconds to begin playing the track for preview purposes. + /// If -1, the track should begin playing at 40% of its length. + /// public int PreviewTime { get; set; } + public string AudioFile { get; set; } public string BackgroundFile { get; set; } diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs index eb05cbaf85..3206f7b3ab 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs @@ -34,8 +34,8 @@ namespace osu.Game.Beatmaps.Drawables /// protected virtual double UnloadDelay => 10000; - protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) - => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay); + protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) => + new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay) { RelativeSizeAxes = Axes.Both }; protected override double TransformDuration => 400; diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 30382c444f..aab8ff6bd6 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -266,6 +266,26 @@ namespace osu.Game.Beatmaps [NotNull] public Track LoadTrack() => loadedTrack = GetBeatmapTrack() ?? GetVirtualTrack(1000); + /// + /// Reads the correct track restart point from beatmap metadata and sets looping to enabled. + /// + public void PrepareTrackForPreviewLooping() + { + Track.Looping = true; + Track.RestartPoint = Metadata.PreviewTime; + + if (Track.RestartPoint == -1) + { + if (!Track.IsLoaded) + { + // force length to be populated (https://github.com/ppy/osu-framework/issues/4202) + Track.Seek(Track.CurrentTime); + } + + Track.RestartPoint = 0.4f * Track.Length; + } + } + /// /// Transfer a valid audio track into this working beatmap. Used as an optimisation to avoid reload / track swap /// across difficulties in the same beatmap set. diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs index ec0e9d5a89..bb743d4ccc 100644 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ b/osu.Game/Collections/CollectionFilterDropdown.cs @@ -29,6 +29,14 @@ namespace osu.Game.Collections /// protected virtual bool ShowManageCollectionsItem => true; + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public new Bindable Current + { + get => current.Current; + set => current.Current = value; + } + private readonly IBindableList collections = new BindableList(); private readonly IBindableList beatmaps = new BindableList(); private readonly BindableList filters = new BindableList(); @@ -36,25 +44,28 @@ namespace osu.Game.Collections [Resolved(CanBeNull = true)] private ManageCollectionsDialog manageCollectionsDialog { get; set; } + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + public CollectionFilterDropdown() { ItemSource = filters; - } - - [BackgroundDependencyLoader(permitNulls: true)] - private void load([CanBeNull] CollectionManager collectionManager) - { - if (collectionManager != null) - collections.BindTo(collectionManager.Collections); - - collections.CollectionChanged += (_, __) => collectionsChanged(); - collectionsChanged(); + Current.Value = new AllBeatmapsCollectionFilterMenuItem(); } protected override void LoadComplete() { base.LoadComplete(); + if (collectionManager != null) + collections.BindTo(collectionManager.Collections); + + // Dropdown has logic which triggers a change on the bindable with every change to the contained items. + // This is not desirable here, as it leads to multiple filter operations running even though nothing has changed. + // An extra bindable is enough to subvert this behaviour. + base.Current = Current; + + collections.BindCollectionChanged((_, __) => collectionsChanged(), true); Current.BindValueChanged(filterChanged, true); } diff --git a/osu.Game/Collections/CollectionFilterMenuItem.cs b/osu.Game/Collections/CollectionFilterMenuItem.cs index 4a489d2945..fe79358223 100644 --- a/osu.Game/Collections/CollectionFilterMenuItem.cs +++ b/osu.Game/Collections/CollectionFilterMenuItem.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 JetBrains.Annotations; using osu.Framework.Bindables; @@ -9,7 +10,7 @@ namespace osu.Game.Collections /// /// A filter. /// - public class CollectionFilterMenuItem + public class CollectionFilterMenuItem : IEquatable { /// /// The collection to filter beatmaps from. @@ -33,6 +34,11 @@ namespace osu.Game.Collections Collection = collection; CollectionName = Collection?.Name.GetBoundCopy() ?? new Bindable("All beatmaps"); } + + public bool Equals(CollectionFilterMenuItem other) + => other != null && CollectionName.Value == other.CollectionName.Value; + + public override int GetHashCode() => CollectionName.Value.GetHashCode(); } public class AllBeatmapsCollectionFilterMenuItem : CollectionFilterMenuItem diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index 569ac749a4..a65d9a415d 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -139,35 +139,43 @@ namespace osu.Game.Collections PostNotification?.Invoke(notification); var collection = readCollections(stream, notification); - bool importCompleted = false; - - Schedule(() => - { - importCollections(collection); - importCompleted = true; - }); - - while (!IsDisposed && !importCompleted) - await Task.Delay(10); + await importCollections(collection); notification.CompletionText = $"Imported {collection.Count} collections"; notification.State = ProgressNotificationState.Completed; } - private void importCollections(List newCollections) + private Task importCollections(List newCollections) { - foreach (var newCol in newCollections) - { - var existing = Collections.FirstOrDefault(c => c.Name == newCol.Name); - if (existing == null) - Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); + var tcs = new TaskCompletionSource(); - foreach (var newBeatmap in newCol.Beatmaps) + Schedule(() => + { + try { - if (!existing.Beatmaps.Contains(newBeatmap)) - existing.Beatmaps.Add(newBeatmap); + foreach (var newCol in newCollections) + { + var existing = Collections.FirstOrDefault(c => c.Name == newCol.Name); + if (existing == null) + Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); + + foreach (var newBeatmap in newCol.Beatmaps) + { + if (!existing.Beatmaps.Contains(newBeatmap)) + existing.Beatmaps.Add(newBeatmap); + } + } + + tcs.SetResult(true); } - } + catch (Exception e) + { + Logger.Error(e, "Failed to import collection."); + tcs.SetException(e); + } + }); + + return tcs.Task; } private List readCollections(Stream stream, ProgressNotification notification = null) diff --git a/osu.Game/Configuration/ModSettingChangeTracker.cs b/osu.Game/Configuration/ModSettingChangeTracker.cs new file mode 100644 index 0000000000..e2ade7dc6a --- /dev/null +++ b/osu.Game/Configuration/ModSettingChangeTracker.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Configuration +{ + /// + /// A helper class for tracking changes to the settings of a set of s. + /// + /// + /// Ensure to dispose when usage is finished. + /// + public class ModSettingChangeTracker : IDisposable + { + /// + /// Notifies that the setting of a has changed. + /// + public Action SettingChanged; + + private readonly List settings = new List(); + + /// + /// Creates a new for a set of s. + /// + /// The set of s whose settings need to be tracked. + public ModSettingChangeTracker(IEnumerable mods) + { + foreach (var mod in mods) + { + foreach (var setting in mod.CreateSettingsControls().OfType()) + { + setting.SettingChanged += () => SettingChanged?.Invoke(mod); + settings.Add(setting); + } + } + } + + public void Dispose() + { + SettingChanged = null; + + foreach (var r in settings) + r.Dispose(); + settings.Clear(); + } + } +} diff --git a/osu.Game/Configuration/ScoreMeterType.cs b/osu.Game/Configuration/ScoreMeterType.cs index b9499c758e..ddbd2327c2 100644 --- a/osu.Game/Configuration/ScoreMeterType.cs +++ b/osu.Game/Configuration/ScoreMeterType.cs @@ -16,12 +16,12 @@ namespace osu.Game.Configuration [Description("Hit Error (right)")] HitErrorRight, - [Description("Hit Error (bottom)")] - HitErrorBottom, - [Description("Hit Error (left+right)")] HitErrorBoth, + [Description("Hit Error (bottom)")] + HitErrorBottom, + [Description("Colour (left)")] ColourLeft, diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 50069be4b2..70d67aaaa0 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -57,6 +57,7 @@ namespace osu.Game.Configuration yield return new SettingsSlider { LabelText = attr.Label, + TooltipText = attr.Description, Current = bNumber, KeyboardStep = 0.1f, }; @@ -67,6 +68,7 @@ namespace osu.Game.Configuration yield return new SettingsSlider { LabelText = attr.Label, + TooltipText = attr.Description, Current = bNumber, KeyboardStep = 0.1f, }; @@ -77,6 +79,7 @@ namespace osu.Game.Configuration yield return new SettingsSlider { LabelText = attr.Label, + TooltipText = attr.Description, Current = bNumber }; @@ -86,6 +89,7 @@ namespace osu.Game.Configuration yield return new SettingsCheckbox { LabelText = attr.Label, + TooltipText = attr.Description, Current = bBool }; @@ -95,6 +99,7 @@ namespace osu.Game.Configuration yield return new SettingsTextBox { LabelText = attr.Label, + TooltipText = attr.Description, Current = bString }; @@ -105,6 +110,7 @@ namespace osu.Game.Configuration var dropdown = (Drawable)Activator.CreateInstance(dropdownType); dropdownType.GetProperty(nameof(SettingsDropdown.LabelText))?.SetValue(dropdown, attr.Label); + dropdownType.GetProperty(nameof(SettingsDropdown.TooltipText))?.SetValue(dropdown, attr.Description); dropdownType.GetProperty(nameof(SettingsDropdown.Current))?.SetValue(dropdown, bindable); yield return dropdown; diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 9581edfc8d..03b8db2cb8 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -38,6 +38,11 @@ namespace osu.Game.Database { private const int import_queue_request_concurrency = 1; + /// + /// The size of a batch import operation before considering it a lower priority operation. + /// + private const int low_priority_import_batch_size = 1; + /// /// A singleton scheduler shared by all . /// @@ -47,6 +52,13 @@ namespace osu.Game.Database /// private static readonly ThreadedTaskScheduler import_scheduler = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager)); + /// + /// A second scheduler for lower priority imports. + /// For simplicity, these will just run in parallel with normal priority imports, but a future refactor would see this implemented via a custom scheduler/queue. + /// See https://gist.github.com/peppy/f0e118a14751fc832ca30dd48ba3876b for an incomplete version of this. + /// + private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager)); + /// /// Set an endpoint for notifications to be posted to. /// @@ -103,8 +115,11 @@ namespace osu.Game.Database /// /// Import one or more items from filesystem . - /// This will post notifications tracking progress. /// + /// + /// This will be treated as a low priority import if more than one path is specified; use to always import at standard priority. + /// This will post notifications tracking progress. + /// /// One or more archive locations on disk. public Task Import(params string[] paths) { @@ -133,13 +148,15 @@ namespace osu.Game.Database var imported = new List(); + bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size; + await Task.WhenAll(tasks.Select(async task => { notification.CancellationToken.ThrowIfCancellationRequested(); try { - var model = await Import(task, notification.CancellationToken); + var model = await Import(task, isLowPriorityImport, notification.CancellationToken); lock (imported) { @@ -193,15 +210,16 @@ namespace osu.Game.Database /// Note that this bypasses the UI flow and should only be used for special cases or testing. /// /// The containing data about the to import. + /// Whether this is a low priority import. /// An optional cancellation token. /// The imported model, if successful. - internal async Task Import(ImportTask task, CancellationToken cancellationToken = default) + internal async Task Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); TModel import; using (ArchiveReader reader = task.GetReader()) - import = await Import(reader, cancellationToken); + import = await Import(reader, lowPriority, cancellationToken); // We may or may not want to delete the file depending on where it is stored. // e.g. reconstructing/repairing database with items from default storage. @@ -226,11 +244,12 @@ namespace osu.Game.Database public Action> PresentImport; /// - /// Import an item from an . + /// Silently import an item from an . /// /// The archive to be imported. + /// Whether this is a low priority import. /// An optional cancellation token. - public Task Import(ArchiveReader archive, CancellationToken cancellationToken = default) + public Task Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -253,7 +272,7 @@ namespace osu.Game.Database return null; } - return Import(model, archive, cancellationToken); + return Import(model, archive, lowPriority, cancellationToken); } /// @@ -303,12 +322,13 @@ namespace osu.Game.Database } /// - /// Import an item from a . + /// Silently import an item from a . /// /// The model to be imported. /// An optional archive to use for model population. + /// Whether this is a low priority import. /// An optional cancellation token. - public virtual async Task Import(TModel item, ArchiveReader archive = null, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () => + public virtual async Task Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () => { cancellationToken.ThrowIfCancellationRequested(); @@ -383,7 +403,7 @@ namespace osu.Game.Database flushEvents(true); return item; - }, cancellationToken, TaskCreationOptions.HideScheduler, import_scheduler).Unwrap(); + }, cancellationToken, TaskCreationOptions.HideScheduler, lowPriority ? import_scheduler_low_priority : import_scheduler).Unwrap(); /// /// Exports an item to a legacy (.zip based) package. @@ -625,7 +645,7 @@ namespace osu.Game.Database /// /// Set a storage with access to an osu-stable install for import purposes. /// - public Func GetStableStorage { private get; set; } + public Func GetStableStorage { private get; set; } /// /// Denotes whether an osu-stable installation is present to perform automated imports from. @@ -638,9 +658,10 @@ namespace osu.Game.Database protected virtual string ImportFromStablePath => null; /// - /// Select paths to import from stable. Default implementation iterates all directories in . + /// Select paths to import from stable where all paths should be absolute. Default implementation iterates all directories in . /// - protected virtual IEnumerable GetStableImportPaths(Storage stableStoage) => stableStoage.GetDirectories(ImportFromStablePath); + protected virtual IEnumerable GetStableImportPaths(Storage storage) => storage.GetDirectories(ImportFromStablePath) + .Select(path => storage.GetFullPath(path)); /// /// Whether this specified path should be removed after successful import. @@ -654,24 +675,33 @@ namespace osu.Game.Database /// public Task ImportFromStableAsync() { - var stable = GetStableStorage?.Invoke(); + var stableStorage = GetStableStorage?.Invoke(); - if (stable == null) + if (stableStorage == null) { Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error); return Task.CompletedTask; } - if (!stable.ExistsDirectory(ImportFromStablePath)) + var storage = PrepareStableStorage(stableStorage); + + if (!storage.ExistsDirectory(ImportFromStablePath)) { // This handles situations like when the user does not have a Skins folder Logger.Log($"No {ImportFromStablePath} folder available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); return Task.CompletedTask; } - return Task.Run(async () => await Import(GetStableImportPaths(GetStableStorage()).Select(f => stable.GetFullPath(f)).ToArray())); + return Task.Run(async () => await Import(GetStableImportPaths(storage).ToArray())); } + /// + /// Run any required traversal operations on the stable storage location before performing operations. + /// + /// The stable storage. + /// The usable storage. Return the unchanged if no traversal is required. + protected virtual Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage; + #endregion /// diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index e3a9a5fe9d..914c8ff78d 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -38,7 +38,12 @@ namespace osu.Game.Graphics.Containers foreach (var link in links) { AddText(text[previousLinkEnd..link.Index]); - AddLink(text.Substring(link.Index, link.Length), link.Action, link.Argument ?? link.Url); + + string displayText = text.Substring(link.Index, link.Length); + string linkArgument = link.Argument ?? link.Url; + string tooltip = displayText == link.Url ? null : link.Url; + + AddLink(displayText, link.Action, linkArgument, tooltip); previousLinkEnd = link.Index + link.Length; } @@ -52,7 +57,7 @@ namespace osu.Game.Graphics.Containers => createLink(AddText(text, creationParameters), new LinkDetails(LinkAction.Custom, null), tooltipText, action); public void AddLink(string text, LinkAction action, string argument, string tooltipText = null, Action creationParameters = null) - => createLink(AddText(text, creationParameters), new LinkDetails(action, argument), null); + => createLink(AddText(text, creationParameters), new LinkDetails(action, argument), tooltipText); public void AddLink(IEnumerable text, LinkAction action = LinkAction.External, string linkArgument = null, string tooltipText = null) { diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index 41fd37a0d7..fbf2ffd4bd 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -18,8 +18,10 @@ namespace osu.Game.Graphics.Containers [Cached(typeof(IPreviewTrackOwner))] public abstract class OsuFocusedOverlayContainer : FocusedOverlayContainer, IPreviewTrackOwner, IKeyBindingHandler { - private SampleChannel samplePopIn; - private SampleChannel samplePopOut; + private Sample samplePopIn; + private Sample samplePopOut; + protected virtual string PopInSampleName => "UI/overlay-pop-in"; + protected virtual string PopOutSampleName => "UI/overlay-pop-out"; protected override bool BlockNonPositionalInput => true; @@ -40,8 +42,8 @@ namespace osu.Game.Graphics.Containers [BackgroundDependencyLoader(true)] private void load(AudioManager audio) { - samplePopIn = audio.Samples.Get(@"UI/overlay-pop-in"); - samplePopOut = audio.Samples.Get(@"UI/overlay-pop-out"); + samplePopIn = audio.Samples.Get(PopInSampleName); + samplePopOut = audio.Samples.Get(PopOutSampleName); } protected override void LoadComplete() diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index 53ee711626..f7914cbbca 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -44,7 +44,7 @@ namespace osu.Game.Graphics [Resolved] private NotificationOverlay notificationOverlay { get; set; } - private SampleChannel shutter; + private Sample shutter; [BackgroundDependencyLoader] private void load(OsuConfigManager config, Storage storage, AudioManager audio) diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index abaae7b43c..b499b26f38 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -22,8 +22,8 @@ namespace osu.Game.Graphics.UserInterface private const int text_size = 17; private const int transition_length = 80; - private SampleChannel sampleClick; - private SampleChannel sampleHover; + private Sample sampleClick; + private Sample sampleHover; private TextContainer text; diff --git a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs index 803facae04..c1963ce62d 100644 --- a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs @@ -17,7 +17,7 @@ namespace osu.Game.Graphics.UserInterface /// public class HoverClickSounds : HoverSounds { - private SampleChannel sampleClick; + private Sample sampleClick; private readonly MouseButton[] buttons; /// diff --git a/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs new file mode 100644 index 0000000000..55f43cfe46 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Configuration; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// Handles debouncing hover sounds at a global level to ensure the effects are not overwhelming. + /// + public abstract class HoverSampleDebounceComponent : CompositeDrawable + { + /// + /// Length of debounce for hover sound playback, in milliseconds. + /// + public double HoverDebounceTime { get; } = 20; + + private Bindable lastPlaybackTime; + + [BackgroundDependencyLoader] + private void load(AudioManager audio, SessionStatics statics) + { + lastPlaybackTime = statics.GetBindable(Static.LastHoverSoundPlaybackTime); + } + + protected override bool OnHover(HoverEvent e) + { + // hover sounds shouldn't be played during scroll operations. + if (e.HasAnyButtonPressed) + return false; + + bool enoughTimePassedSinceLastPlayback = !lastPlaybackTime.Value.HasValue || Time.Current - lastPlaybackTime.Value >= HoverDebounceTime; + + if (enoughTimePassedSinceLastPlayback) + { + PlayHoverSample(); + lastPlaybackTime.Value = Time.Current; + } + + return false; + } + + public abstract void PlayHoverSample(); + } +} diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index a1d06711db..00cf5798e7 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs @@ -5,12 +5,10 @@ using System.ComponentModel; 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.Input.Events; using osu.Game.Configuration; +using osu.Framework.Utils; namespace osu.Game.Graphics.UserInterface { @@ -18,19 +16,12 @@ namespace osu.Game.Graphics.UserInterface /// Adds hover sounds to a drawable. /// Does not draw anything. /// - public class HoverSounds : CompositeDrawable + public class HoverSounds : HoverSampleDebounceComponent { - private SampleChannel sampleHover; - - /// - /// Length of debounce for hover sound playback, in milliseconds. - /// - public double HoverDebounceTime { get; } = 20; + private Sample sampleHover; protected readonly HoverSampleSet SampleSet; - private Bindable lastPlaybackTime; - public HoverSounds(HoverSampleSet sampleSet = HoverSampleSet.Normal) { SampleSet = sampleSet; @@ -40,22 +31,13 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(AudioManager audio, SessionStatics statics) { - lastPlaybackTime = statics.GetBindable(Static.LastHoverSoundPlaybackTime); - sampleHover = audio.Samples.Get($@"UI/generic-hover{SampleSet.GetDescription()}"); } - protected override bool OnHover(HoverEvent e) + public override void PlayHoverSample() { - bool enoughTimePassedSinceLastPlayback = !lastPlaybackTime.Value.HasValue || Time.Current - lastPlaybackTime.Value >= HoverDebounceTime; - - if (enoughTimePassedSinceLastPlayback) - { - sampleHover?.Play(); - lastPlaybackTime.Value = Time.Current; - } - - return base.OnHover(e); + sampleHover.Frequency.Value = 0.96 + RNG.NextDouble(0.08); + sampleHover.Play(); } } @@ -68,6 +50,9 @@ namespace osu.Game.Graphics.UserInterface Normal, [Description("-softer")] - Soft + Soft, + + [Description("-toolbar")] + Toolbar } } diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs index f6effa0834..5f2d884cd7 100644 --- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs @@ -46,8 +46,8 @@ namespace osu.Game.Graphics.UserInterface protected readonly Nub Nub; private readonly OsuTextFlowContainer labelText; - private SampleChannel sampleChecked; - private SampleChannel sampleUnchecked; + private Sample sampleChecked; + private Sample sampleUnchecked; public OsuCheckbox(bool nubOnRight = true) { @@ -64,7 +64,7 @@ namespace osu.Game.Graphics.UserInterface RelativeSizeAxes = Axes.X, }, Nub = new Nub(), - new HoverClickSounds() + new HoverSounds() }; if (nubOnRight) diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index d0356e77c7..f58962f8e1 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -25,7 +25,7 @@ namespace osu.Game.Graphics.UserInterface /// private const int max_decimal_digits = 5; - private SampleChannel sample; + private Sample sample; private double lastSampleTime; private T lastSampleValue; @@ -155,16 +155,15 @@ namespace osu.Game.Graphics.UserInterface return; lastSampleValue = value; - lastSampleTime = Clock.CurrentTime; - sample.Frequency.Value = 1 + NormalizedValue * 0.2f; + var channel = sample.Play(); + + channel.Frequency.Value = 1 + NormalizedValue * 0.2f; if (NormalizedValue == 0) - sample.Frequency.Value -= 0.4f; + channel.Frequency.Value -= 0.4f; else if (NormalizedValue == 1) - sample.Frequency.Value += 0.4f; - - sample.Play(); + channel.Frequency.Value += 0.4f; } private void updateTooltipText(T value) diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 1ec4dfc91a..75af9efc38 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -23,11 +23,11 @@ namespace osu.Game.Graphics.UserInterface { public class OsuTextBox : BasicTextBox { - private readonly SampleChannel[] textAddedSamples = new SampleChannel[4]; - private SampleChannel capsTextAddedSample; - private SampleChannel textRemovedSample; - private SampleChannel textCommittedSample; - private SampleChannel caretMovedSample; + private readonly Sample[] textAddedSamples = new Sample[4]; + private Sample capsTextAddedSample; + private Sample textRemovedSample; + private Sample textCommittedSample; + private Sample caretMovedSample; /// /// Whether to allow playing a different samples based on the type of character. diff --git a/osu.Game/Graphics/UserInterface/ProgressBar.cs b/osu.Game/Graphics/UserInterface/ProgressBar.cs index d271cd121c..50367e600e 100644 --- a/osu.Game/Graphics/UserInterface/ProgressBar.cs +++ b/osu.Game/Graphics/UserInterface/ProgressBar.cs @@ -40,8 +40,19 @@ namespace osu.Game.Graphics.UserInterface set => CurrentNumber.Value = value; } - public ProgressBar() + private readonly bool allowSeek; + + public override bool HandlePositionalInput => allowSeek; + public override bool HandleNonPositionalInput => allowSeek; + + /// + /// Construct a new progress bar. + /// + /// Whether the user should be allowed to click/drag to adjust the value. + public ProgressBar(bool allowSeek) { + this.allowSeek = allowSeek; + CurrentNumber.MinValue = 0; CurrentNumber.MaxValue = 1; RelativeSizeAxes = Axes.X; diff --git a/osu.Game/IO/StableStorage.cs b/osu.Game/IO/StableStorage.cs new file mode 100644 index 0000000000..d4b0d300ff --- /dev/null +++ b/osu.Game/IO/StableStorage.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; +using System.IO; +using System.Linq; +using osu.Framework.Platform; + +namespace osu.Game.IO +{ + /// + /// A storage pointing to an osu-stable installation. + /// Provides methods for handling installations with a custom Song folder location. + /// + public class StableStorage : DesktopStorage + { + private const string stable_default_songs_path = "Songs"; + + private readonly DesktopGameHost host; + private readonly Lazy songsPath; + + public StableStorage(string path, DesktopGameHost host) + : base(path, host) + { + this.host = host; + + songsPath = new Lazy(locateSongsDirectory); + } + + /// + /// Returns a pointing to the osu-stable Songs directory. + /// + public Storage GetSongStorage() => new DesktopStorage(songsPath.Value, host); + + private string locateSongsDirectory() + { + var configFile = GetFiles(".", $"osu!.{Environment.UserName}.cfg").SingleOrDefault(); + + if (configFile != null) + { + using (var stream = GetStream(configFile)) + using (var textReader = new StreamReader(stream)) + { + string line; + + while ((line = textReader.ReadLine()) != null) + { + if (!line.StartsWith("BeatmapDirectory", StringComparison.OrdinalIgnoreCase)) continue; + + var customDirectory = line.Split('=').LastOrDefault()?.Trim(); + if (customDirectory != null && Path.IsPathFullyQualified(customDirectory)) + return customDirectory; + + break; + } + } + } + + return GetFullPath(stable_default_songs_path); + } + } +} diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 2aaea22155..8ffa0221c8 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -25,6 +25,8 @@ namespace osu.Game.Online.API { private readonly OsuConfigManager config; + private readonly string versionHash; + private readonly OAuth authentication; private readonly Queue queue = new Queue(); @@ -56,9 +58,10 @@ namespace osu.Game.Online.API private readonly Logger log; - public APIAccess(OsuConfigManager config, EndpointConfiguration endpointConfiguration) + public APIAccess(OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash) { this.config = config; + this.versionHash = versionHash; APIEndpointUrl = endpointConfiguration.APIEndpointUrl; WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl; @@ -243,6 +246,8 @@ namespace osu.Game.Online.API this.password = password; } + public IHubClientConnector GetHubConnector(string clientName, string endpoint) => new HubClientConnector(clientName, endpoint, this, versionHash); + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Debug.Assert(State.Value == APIState.Offline); diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 3e996ac97f..943b52db88 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -83,6 +83,8 @@ namespace osu.Game.Online.API state.Value = APIState.Offline; } + public IHubClientConnector GetHubConnector(string clientName, string endpoint) => null; + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Thread.Sleep(200); diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 1951dfaf40..3a77b9cfee 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.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. +#nullable enable + using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Game.Users; @@ -95,6 +97,13 @@ namespace osu.Game.Online.API /// void Logout(); + /// + /// Constructs a new . May be null if not supported. + /// + /// The name of the client this connector connects for, used for logging. + /// The endpoint to the hub. + IHubClientConnector? GetHubConnector(string clientName, string endpoint); + /// /// Create a new user account. This is a blocking operation. /// @@ -102,6 +111,6 @@ namespace osu.Game.Online.API /// The username to create the account with. /// The password to create the account with. /// Any errors encoutnered during account creation. - RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password); + RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password); } } diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs index 99e87677fa..dd854acc32 100644 --- a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs +++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs @@ -3,6 +3,7 @@ using System.Buffers; using System.Collections.Generic; +using System.Diagnostics; using System.Text; using MessagePack; using MessagePack.Formatters; @@ -41,6 +42,13 @@ namespace osu.Game.Online.API primitiveFormatter.Serialize(ref writer, b.Value, options); break; + case IBindable u: + // A mod with unknown (e.g. enum) generic type. + var valueMethod = u.GetType().GetProperty(nameof(IBindable.Value)); + Debug.Assert(valueMethod != null); + primitiveFormatter.Serialize(ref writer, valueMethod.GetValue(u), options); + break; + default: // fall back for non-bindable cases. primitiveFormatter.Serialize(ref writer, kvp.Value, options); diff --git a/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs index bcc8721400..172fa3a583 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs @@ -22,7 +22,7 @@ namespace osu.Game.Online.API.Requests.Responses public double? PP { get; set; } [JsonProperty(@"room_id")] - public int RoomID { get; set; } + public long RoomID { get; set; } [JsonProperty("total_score")] public long TotalScore { get; set; } diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index d2a117876d..b80720a0aa 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -49,6 +49,18 @@ namespace osu.Game.Online.Chat // Unicode emojis private static readonly Regex emoji_regex = new Regex(@"(\uD83D[\uDC00-\uDE4F])"); + /// + /// The root URL for the website, used for chat link matching. + /// + public static string WebsiteRootUrl + { + set => websiteRootUrl = value + .Trim('/') // trim potential trailing slash/ + .Split('/').Last(); // only keep domain name, ignoring protocol. + } + + private static string websiteRootUrl = "osu.ppy.sh"; + private static void handleMatches(Regex regex, string display, string link, MessageFormatterResult result, int startIndex = 0, LinkAction? linkActionOverride = null, char[] escapeChars = null) { int captureOffset = 0; @@ -119,22 +131,42 @@ namespace osu.Game.Online.Chat case "http": case "https": // length > 3 since all these links need another argument to work - if (args.Length > 3 && args[1] == "osu.ppy.sh") + if (args.Length > 3 && args[1].EndsWith(websiteRootUrl, StringComparison.OrdinalIgnoreCase)) { + var mainArg = args[3]; + switch (args[2]) { + // old site only case "b": case "beatmaps": - return new LinkDetails(LinkAction.OpenBeatmap, args[3]); + { + string trimmed = mainArg.Split('?').First(); + if (int.TryParse(trimmed, out var id)) + return new LinkDetails(LinkAction.OpenBeatmap, id.ToString()); + + break; + } case "s": case "beatmapsets": case "d": - return new LinkDetails(LinkAction.OpenBeatmapSet, args[3]); + { + if (args.Length > 4 && int.TryParse(args[4], out var id)) + // https://osu.ppy.sh/beatmapsets/1154158#osu/2768184 + return new LinkDetails(LinkAction.OpenBeatmap, id.ToString()); + + // https://osu.ppy.sh/beatmapsets/1154158#whatever + string trimmed = mainArg.Split('#').First(); + if (int.TryParse(trimmed, out id)) + return new LinkDetails(LinkAction.OpenBeatmapSet, id.ToString()); + + break; + } case "u": case "users": - return new LinkDetails(LinkAction.OpenUserProfile, args[3]); + return new LinkDetails(LinkAction.OpenUserProfile, mainArg); } } @@ -183,10 +215,9 @@ namespace osu.Game.Online.Chat case "osump": return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]); - - default: - return new LinkDetails(LinkAction.External, null); } + + return new LinkDetails(LinkAction.External, null); } private static MessageFormatterResult format(string toFormat, int startIndex = 0, int space = 3) @@ -259,8 +290,9 @@ namespace osu.Game.Online.Chat public class LinkDetails { - public LinkAction Action; - public string Argument; + public readonly LinkAction Action; + + public readonly string Argument; public LinkDetails(LinkAction action, string argument) { diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs new file mode 100644 index 0000000000..fdb21c5000 --- /dev/null +++ b/osu.Game/Online/HubClientConnector.cs @@ -0,0 +1,208 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using osu.Framework; +using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Game.Online.API; + +namespace osu.Game.Online +{ + public class HubClientConnector : IHubClientConnector + { + /// + /// Invoked whenever a new hub connection is built, to configure it before it's started. + /// + public Action? ConfigureConnection { get; set; } + + private readonly string clientName; + private readonly string endpoint; + private readonly string versionHash; + private readonly IAPIProvider api; + + /// + /// The current connection opened by this connector. + /// + public HubConnection? CurrentConnection { get; private set; } + + /// + /// Whether this is connected to the hub, use to access the connection, if this is true. + /// + public IBindable IsConnected => isConnected; + + private readonly Bindable isConnected = new Bindable(); + private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1); + private CancellationTokenSource connectCancelSource = new CancellationTokenSource(); + + private readonly IBindable apiState = new Bindable(); + + /// + /// Constructs a new . + /// + /// The name of the client this connector connects for, used for logging. + /// The endpoint to the hub. + /// An API provider used to react to connection state changes. + /// The hash representing the current game version, used for verification purposes. + public HubClientConnector(string clientName, string endpoint, IAPIProvider api, string versionHash) + { + this.clientName = clientName; + this.endpoint = endpoint; + this.api = api; + this.versionHash = versionHash; + + apiState.BindTo(api.State); + apiState.BindValueChanged(state => + { + switch (state.NewValue) + { + case APIState.Failing: + case APIState.Offline: + Task.Run(() => disconnect(true)); + break; + + case APIState.Online: + Task.Run(connect); + break; + } + }, true); + } + + private async Task connect() + { + cancelExistingConnect(); + + if (!await connectionLock.WaitAsync(10000)) + throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck."); + + try + { + while (apiState.Value == APIState.Online) + { + // ensure any previous connection was disposed. + // this will also create a new cancellation token source. + await disconnect(false); + + // this token will be valid for the scope of this connection. + // if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere. + var cancellationToken = connectCancelSource.Token; + + cancellationToken.ThrowIfCancellationRequested(); + + Logger.Log($"{clientName} connecting...", LoggingTarget.Network); + + try + { + // importantly, rebuild the connection each attempt to get an updated access token. + CurrentConnection = buildConnection(cancellationToken); + + await CurrentConnection.StartAsync(cancellationToken); + + Logger.Log($"{clientName} connected!", LoggingTarget.Network); + isConnected.Value = true; + return; + } + catch (OperationCanceledException) + { + //connection process was cancelled. + throw; + } + catch (Exception e) + { + Logger.Log($"{clientName} connection error: {e}", LoggingTarget.Network); + + // retry on any failure. + await Task.Delay(5000, cancellationToken); + } + } + } + finally + { + connectionLock.Release(); + } + } + + private HubConnection buildConnection(CancellationToken cancellationToken) + { + var builder = new HubConnectionBuilder() + .WithUrl(endpoint, options => + { + options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); + options.Headers.Add("OsuVersionHash", versionHash); + }); + + if (RuntimeInfo.SupportsJIT) + builder.AddMessagePackProtocol(); + else + { + // eventually we will precompile resolvers for messagepack, but this isn't working currently + // see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308. + builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }); + } + + var newConnection = builder.Build(); + + ConfigureConnection?.Invoke(newConnection); + + newConnection.Closed += ex => onConnectionClosed(ex, cancellationToken); + return newConnection; + } + + private Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) + { + isConnected.Value = false; + + Logger.Log(ex != null ? $"{clientName} lost connection: {ex}" : $"{clientName} disconnected", LoggingTarget.Network); + + // make sure a disconnect wasn't triggered (and this is still the active connection). + if (!cancellationToken.IsCancellationRequested) + Task.Run(connect, default); + + return Task.CompletedTask; + } + + private async Task disconnect(bool takeLock) + { + cancelExistingConnect(); + + if (takeLock) + { + if (!await connectionLock.WaitAsync(10000)) + throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck."); + } + + try + { + if (CurrentConnection != null) + await CurrentConnection.DisposeAsync(); + } + finally + { + CurrentConnection = null; + if (takeLock) + connectionLock.Release(); + } + } + + private void cancelExistingConnect() + { + connectCancelSource.Cancel(); + connectCancelSource = new CancellationTokenSource(); + } + + public override string ToString() => $"Connector for {clientName} ({(IsConnected.Value ? "connected" : "not connected")}"; + + public void Dispose() + { + apiState.UnbindAll(); + cancelExistingConnect(); + } + } +} diff --git a/osu.Game/Online/IHubClientConnector.cs b/osu.Game/Online/IHubClientConnector.cs new file mode 100644 index 0000000000..d2ceb1f030 --- /dev/null +++ b/osu.Game/Online/IHubClientConnector.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. + +#nullable enable + +using System; +using Microsoft.AspNetCore.SignalR.Client; +using osu.Framework.Bindables; +using osu.Game.Online.API; + +namespace osu.Game.Online +{ + /// + /// A component that manages the life cycle of a connection to a SignalR Hub. + /// Should generally be retrieved from an . + /// + public interface IHubClientConnector : IDisposable + { + /// + /// The current connection opened by this connector. + /// + HubConnection? CurrentConnection { get; } + + /// + /// Whether this is connected to the hub, use to access the connection, if this is true. + /// + IBindable IsConnected { get; } + + /// + /// Invoked whenever a new hub connection is built, to configure it before it's started. + /// + public Action? ConfigureConnection { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 493518ac80..95d76f384f 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -3,17 +3,12 @@ #nullable enable -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Logging; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -21,106 +16,50 @@ namespace osu.Game.Online.Multiplayer { public class MultiplayerClient : StatefulMultiplayerClient { - public override IBindable IsConnected => isConnected; - - private readonly Bindable isConnected = new Bindable(); - private readonly IBindable apiState = new Bindable(); - - private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1); - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - private HubConnection? connection; - - private CancellationTokenSource connectCancelSource = new CancellationTokenSource(); - private readonly string endpoint; + private IHubClientConnector? connector; + + public override IBindable IsConnected { get; } = new BindableBool(); + + private HubConnection? connection => connector?.CurrentConnection; + public MultiplayerClient(EndpointConfiguration endpoints) { endpoint = endpoints.MultiplayerEndpointUrl; } [BackgroundDependencyLoader] - private void load() + private void load(IAPIProvider api) { - apiState.BindTo(api.State); - apiState.BindValueChanged(apiStateChanged, true); - } + connector = api.GetHubConnector(nameof(MultiplayerClient), endpoint); - private void apiStateChanged(ValueChangedEvent state) - { - switch (state.NewValue) + if (connector != null) { - case APIState.Failing: - case APIState.Offline: - Task.Run(() => disconnect(true)); - break; - - case APIState.Online: - Task.Run(connect); - break; - } - } - - private async Task connect() - { - cancelExistingConnect(); - - if (!await connectionLock.WaitAsync(10000)) - throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck."); - - try - { - while (api.State.Value == APIState.Online) + connector.ConfigureConnection = connection => { - // ensure any previous connection was disposed. - // this will also create a new cancellation token source. - await disconnect(false); + // this is kind of SILLY + // https://github.com/dotnet/aspnetcore/issues/15198 + connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged); + connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined); + connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft); + connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged); + connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged); + connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); + connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); + connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted); + connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); + connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); + connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); + }; - // this token will be valid for the scope of this connection. - // if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere. - var cancellationToken = connectCancelSource.Token; - - cancellationToken.ThrowIfCancellationRequested(); - - Logger.Log("Multiplayer client connecting...", LoggingTarget.Network); - - try - { - // importantly, rebuild the connection each attempt to get an updated access token. - connection = createConnection(cancellationToken); - - await connection.StartAsync(cancellationToken); - - Logger.Log("Multiplayer client connected!", LoggingTarget.Network); - isConnected.Value = true; - return; - } - catch (OperationCanceledException) - { - //connection process was cancelled. - throw; - } - catch (Exception e) - { - Logger.Log($"Multiplayer client connection error: {e}", LoggingTarget.Network); - - // retry on any failure. - await Task.Delay(5000, cancellationToken); - } - } - } - finally - { - connectionLock.Release(); + IsConnected.BindTo(connector.IsConnected); } } protected override Task JoinRoom(long roomId) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.FromCanceled(new CancellationToken(true)); return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId); @@ -128,7 +67,7 @@ namespace osu.Game.Online.Multiplayer protected override Task LeaveRoomInternal() { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.FromCanceled(new CancellationToken(true)); return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom)); @@ -136,7 +75,7 @@ namespace osu.Game.Online.Multiplayer public override Task TransferHost(int userId) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId); @@ -144,7 +83,7 @@ namespace osu.Game.Online.Multiplayer public override Task ChangeSettings(MultiplayerRoomSettings settings) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings); @@ -152,7 +91,7 @@ namespace osu.Game.Online.Multiplayer public override Task ChangeState(MultiplayerUserState newState) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState); @@ -160,7 +99,7 @@ namespace osu.Game.Online.Multiplayer public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability); @@ -168,7 +107,7 @@ namespace osu.Game.Online.Multiplayer public override Task ChangeUserMods(IEnumerable newMods) { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods); @@ -176,90 +115,16 @@ namespace osu.Game.Online.Multiplayer public override Task StartMatch() { - if (!isConnected.Value) + if (!IsConnected.Value) return Task.CompletedTask; return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch)); } - private async Task disconnect(bool takeLock) - { - cancelExistingConnect(); - - if (takeLock) - { - if (!await connectionLock.WaitAsync(10000)) - throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck."); - } - - try - { - if (connection != null) - await connection.DisposeAsync(); - } - finally - { - connection = null; - if (takeLock) - connectionLock.Release(); - } - } - - private void cancelExistingConnect() - { - connectCancelSource.Cancel(); - connectCancelSource = new CancellationTokenSource(); - } - - private HubConnection createConnection(CancellationToken cancellationToken) - { - var builder = new HubConnectionBuilder() - .WithUrl(endpoint, options => { options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); }); - - if (RuntimeInfo.SupportsJIT) - builder.AddMessagePackProtocol(); - else - { - // eventually we will precompile resolvers for messagepack, but this isn't working currently - // see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308. - builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }); - } - - var newConnection = builder.Build(); - - // this is kind of SILLY - // https://github.com/dotnet/aspnetcore/issues/15198 - newConnection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged); - newConnection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined); - newConnection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft); - newConnection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged); - newConnection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged); - newConnection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); - newConnection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); - newConnection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted); - newConnection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); - newConnection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); - - newConnection.Closed += ex => - { - isConnected.Value = false; - - Logger.Log(ex != null ? $"Multiplayer client lost connection: {ex}" : "Multiplayer client disconnected", LoggingTarget.Network); - - // make sure a disconnect wasn't triggered (and this is still the active connection). - if (!cancellationToken.IsCancellationRequested) - Task.Run(connect, default); - - return Task.CompletedTask; - }; - return newConnection; - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - - cancelExistingConnect(); + connector?.Dispose(); } } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index 4fb9d724b5..7d6c76bc2f 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -36,18 +36,23 @@ namespace osu.Game.Online.Multiplayer [Key(5)] public IEnumerable AllowedMods { get; set; } = Enumerable.Empty(); + [Key(6)] + public long PlaylistItemId { get; set; } + public bool Equals(MultiplayerRoomSettings other) => BeatmapID == other.BeatmapID && BeatmapChecksum == other.BeatmapChecksum && RequiredMods.SequenceEqual(other.RequiredMods) && AllowedMods.SequenceEqual(other.AllowedMods) && RulesetID == other.RulesetID - && Name.Equals(other.Name, StringComparison.Ordinal); + && Name.Equals(other.Name, StringComparison.Ordinal) + && PlaylistItemId == other.PlaylistItemId; public override string ToString() => $"Name:{Name}" + $" Beatmap:{BeatmapID} ({BeatmapChecksum})" + $" RequiredMods:{string.Join(',', RequiredMods)}" + $" AllowedMods:{string.Join(',', AllowedMods)}" - + $" Ruleset:{RulesetID}"; + + $" Ruleset:{RulesetID}" + + $" Item:{PlaylistItemId}"; } } diff --git a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs index f454fe619b..bfd505fb19 100644 --- a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs @@ -66,6 +66,8 @@ namespace osu.Game.Online.Multiplayer /// public readonly BindableList CurrentMatchPlayingUserIds = new BindableList(); + public readonly Bindable CurrentMatchPlayingItem = new Bindable(); + /// /// The corresponding to the local player, if available. /// @@ -92,12 +94,14 @@ namespace osu.Game.Online.Multiplayer [Resolved] private RulesetStore rulesets { get; set; } = null!; + // Only exists for compatibility with old osu-server-spectator build. + // Todo: Can be removed on 2021/02/26. + private long defaultPlaylistItemId; + private Room? apiRoom; - // Todo: This is temporary, until the multiplayer server returns the item id on match start or otherwise. - private int playlistItemId; - - protected StatefulMultiplayerClient() + [BackgroundDependencyLoader] + private void load() { IsConnected.BindValueChanged(connected => { @@ -141,7 +145,7 @@ namespace osu.Game.Online.Multiplayer { Room = joinedRoom; apiRoom = room; - playlistItemId = room.Playlist.SingleOrDefault()?.ID ?? 0; + defaultPlaylistItemId = apiRoom.Playlist.FirstOrDefault()?.ID ?? 0; }, cancellationSource.Token); // Update room settings. @@ -217,7 +221,7 @@ namespace osu.Game.Online.Multiplayer BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, RulesetID = item.GetOr(existingPlaylistItem).RulesetID, RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods, - AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods + AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods, }); } @@ -505,14 +509,13 @@ namespace osu.Game.Online.Multiplayer Room.Settings = settings; apiRoom.Name.Value = Room.Settings.Name; - // The playlist update is delayed until an online beatmap lookup (below) succeeds. - // In-order for the client to not display an outdated beatmap, the playlist is forcefully cleared here. - apiRoom.Playlist.Clear(); + // The current item update is delayed until an online beatmap lookup (below) succeeds. + // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here. + CurrentMatchPlayingItem.Value = null; RoomUpdated?.Invoke(); var req = new GetBeatmapSetRequest(settings.BeatmapID, BeatmapSetLookupType.BeatmapId); - req.Success += res => { if (cancellationToken.IsCancellationRequested) @@ -539,18 +542,30 @@ namespace osu.Game.Online.Multiplayer var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset)); var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset)); - PlaylistItem playlistItem = new PlaylistItem + // Try to retrieve the existing playlist item from the API room. + var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); + + if (playlistItem != null) + updateItem(playlistItem); + else { - ID = playlistItemId, - Beatmap = { Value = beatmap }, - Ruleset = { Value = ruleset.RulesetInfo }, - }; + // An existing playlist item does not exist, so append a new one. + updateItem(playlistItem = new PlaylistItem()); + apiRoom.Playlist.Add(playlistItem); + } - playlistItem.RequiredMods.AddRange(mods); - playlistItem.AllowedMods.AddRange(allowedMods); + CurrentMatchPlayingItem.Value = playlistItem; - apiRoom.Playlist.Clear(); // Clearing should be unnecessary, but here for sanity. - apiRoom.Playlist.Add(playlistItem); + void updateItem(PlaylistItem item) + { + item.ID = settings.PlaylistItemId == 0 ? defaultPlaylistItemId : settings.PlaylistItemId; + item.Beatmap.Value = beatmap; + item.Ruleset.Value = ruleset.RulesetInfo; + item.RequiredMods.Clear(); + item.RequiredMods.AddRange(mods); + item.AllowedMods.Clear(); + item.AllowedMods.AddRange(allowedMods); + } } /// diff --git a/osu.Game/Online/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs index c9fb70f0cc..8868f90524 100644 --- a/osu.Game/Online/OnlineViewContainer.cs +++ b/osu.Game/Online/OnlineViewContainer.cs @@ -15,7 +15,7 @@ namespace osu.Game.Online /// A for displaying online content which require a local user to be logged in. /// Shows its children only when the local user is logged in and supports displaying a placeholder if not. /// - public abstract class OnlineViewContainer : Container + public class OnlineViewContainer : Container { protected LoadingSpinner LoadingSpinner { get; private set; } @@ -30,7 +30,7 @@ namespace osu.Game.Online [Resolved] protected IAPIProvider API { get; private set; } - protected OnlineViewContainer(string placeholderMessage) + public OnlineViewContainer(string placeholderMessage) { this.placeholderMessage = placeholderMessage; } diff --git a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs index afd0dadc7e..d4303e77df 100644 --- a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs @@ -9,11 +9,11 @@ namespace osu.Game.Online.Rooms { public class CreateRoomScoreRequest : APIRequest { - private readonly int roomId; - private readonly int playlistItemId; + private readonly long roomId; + private readonly long playlistItemId; private readonly string versionHash; - public CreateRoomScoreRequest(int roomId, int playlistItemId, string versionHash) + public CreateRoomScoreRequest(long roomId, long playlistItemId, string versionHash) { this.roomId = roomId; this.playlistItemId = playlistItemId; diff --git a/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs b/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs index 15f1221a00..67e2a2b27f 100644 --- a/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs @@ -7,9 +7,9 @@ namespace osu.Game.Online.Rooms { public class GetRoomLeaderboardRequest : APIRequest { - private readonly int roomId; + private readonly long roomId; - public GetRoomLeaderboardRequest(int roomId) + public GetRoomLeaderboardRequest(long roomId) { this.roomId = roomId; } diff --git a/osu.Game/Online/Rooms/GetRoomRequest.cs b/osu.Game/Online/Rooms/GetRoomRequest.cs index ce117075c7..853873901e 100644 --- a/osu.Game/Online/Rooms/GetRoomRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomRequest.cs @@ -7,9 +7,9 @@ namespace osu.Game.Online.Rooms { public class GetRoomRequest : APIRequest { - public readonly int RoomId; + public readonly long RoomId; - public GetRoomRequest(int roomId) + public GetRoomRequest(long roomId) { RoomId = roomId; } diff --git a/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs b/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs index 43f80a2dc4..abce2093e3 100644 --- a/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs +++ b/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs @@ -15,8 +15,8 @@ namespace osu.Game.Online.Rooms /// public class IndexPlaylistScoresRequest : APIRequest { - public readonly int RoomId; - public readonly int PlaylistItemId; + public readonly long RoomId; + public readonly long PlaylistItemId; [CanBeNull] public readonly Cursor Cursor; @@ -24,13 +24,13 @@ namespace osu.Game.Online.Rooms [CanBeNull] public readonly IndexScoresParams IndexParams; - public IndexPlaylistScoresRequest(int roomId, int playlistItemId) + public IndexPlaylistScoresRequest(long roomId, long playlistItemId) { RoomId = roomId; PlaylistItemId = playlistItemId; } - public IndexPlaylistScoresRequest(int roomId, int playlistItemId, [NotNull] Cursor cursor, [NotNull] IndexScoresParams indexParams) + public IndexPlaylistScoresRequest(long roomId, long playlistItemId, [NotNull] Cursor cursor, [NotNull] IndexScoresParams indexParams) : this(roomId, playlistItemId) { Cursor = cursor; diff --git a/osu.Game/Online/Rooms/ItemAttemptsCount.cs b/osu.Game/Online/Rooms/ItemAttemptsCount.cs new file mode 100644 index 0000000000..298603d778 --- /dev/null +++ b/osu.Game/Online/Rooms/ItemAttemptsCount.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.Rooms +{ + /// + /// Represents attempts on a specific playlist item. + /// + public class ItemAttemptsCount + { + [JsonProperty("id")] + public int PlaylistItemID { get; set; } + + [JsonProperty("attempts")] + public int Attempts { get; set; } + } +} diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index 677a3d3026..30c1d2f826 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -18,7 +18,7 @@ namespace osu.Game.Online.Rooms public class MultiplayerScore { [JsonProperty("id")] - public int ID { get; set; } + public long ID { get; set; } [JsonProperty("user")] public User User { get; set; } diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs index ad4b3c5151..d6f4c45a75 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Logging; +using osu.Framework.Threading; using osu.Game.Beatmaps; namespace osu.Game.Online.Rooms @@ -24,19 +25,33 @@ namespace osu.Game.Online.Rooms /// public IBindable Availability => availability; - private readonly Bindable availability = new Bindable(); + private readonly Bindable availability = new Bindable(BeatmapAvailability.LocallyAvailable()); - public OnlinePlayBeatmapAvailablilityTracker() - { - State.BindValueChanged(_ => updateAvailability()); - Progress.BindValueChanged(_ => updateAvailability(), true); - } + private ScheduledDelegate progressUpdate; protected override void LoadComplete() { base.LoadComplete(); - SelectedItem.BindValueChanged(item => Model.Value = item.NewValue?.Beatmap.Value.BeatmapSet, true); + SelectedItem.BindValueChanged(item => + { + // the underlying playlist is regularly cleared for maintenance purposes (things which probably need to be fixed eventually). + // to avoid exposing a state change when there may actually be none, ignore all nulls for now. + if (item.NewValue == null) + return; + + Model.Value = item.NewValue.Beatmap.Value.BeatmapSet; + }, true); + + Progress.BindValueChanged(_ => + { + // incoming progress changes are going to be at a very high rate. + // we don't want to flood the network with this, so rate limit how often we send progress updates. + if (progressUpdate?.Completed != false) + progressUpdate = Scheduler.AddDelayed(updateAvailability, progressUpdate == null ? 0 : 500); + }); + + State.BindValueChanged(_ => updateAvailability(), true); } protected override bool VerifyDatabasedModel(BeatmapSetInfo databasedSet) diff --git a/osu.Game/Online/Rooms/PlaylistAggregateScore.cs b/osu.Game/Online/Rooms/PlaylistAggregateScore.cs new file mode 100644 index 0000000000..61e0951cd5 --- /dev/null +++ b/osu.Game/Online/Rooms/PlaylistAggregateScore.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 Newtonsoft.Json; + +namespace osu.Game.Online.Rooms +{ + /// + /// Represents aggregated score for the local user for a playlist. + /// + public class PlaylistAggregateScore + { + [JsonProperty("playlist_item_attempts")] + public ItemAttemptsCount[] PlaylistItemAttempts { get; set; } + } +} diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index ada2140ca6..1d409d4b56 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -15,7 +15,7 @@ namespace osu.Game.Online.Rooms public class PlaylistItem : IEquatable { [JsonProperty("id")] - public int ID { get; set; } + public long ID { get; set; } [JsonProperty("beatmap_id")] public int BeatmapID { get; set; } @@ -23,6 +23,12 @@ namespace osu.Game.Online.Rooms [JsonProperty("ruleset_id")] public int RulesetID { get; set; } + /// + /// Whether this is still a valid selection for the . + /// + [JsonProperty("expired")] + public bool Expired { get; set; } + [JsonIgnore] public readonly Bindable Beatmap = new Bindable(); diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 763ba25d52..b28680ffef 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -17,7 +17,7 @@ namespace osu.Game.Online.Rooms { [Cached] [JsonProperty("id")] - public readonly Bindable RoomID = new Bindable(); + public readonly Bindable RoomID = new Bindable(); [Cached] [JsonProperty("name")] @@ -72,6 +72,10 @@ namespace osu.Game.Online.Rooms [JsonIgnore] public readonly Bindable MaxParticipants = new Bindable(); + [Cached] + [JsonProperty("current_user_score")] + public readonly Bindable UserScore = new Bindable(); + [Cached] [JsonProperty("recent_participants")] public readonly BindableList RecentParticipants = new BindableList(); @@ -144,10 +148,17 @@ namespace osu.Game.Online.Rooms MaxParticipants.Value = other.MaxParticipants.Value; ParticipantCount.Value = other.ParticipantCount.Value; EndDate.Value = other.EndDate.Value; + UserScore.Value = other.UserScore.Value; if (EndDate.Value != null && DateTimeOffset.Now >= EndDate.Value) Status.Value = new RoomStatusEnded(); + // Todo: This is not the best way/place to do this, but the intention is to display all playlist items when the room has ended, + // and display only the non-expired playlist items while the room is still active. In order to achieve this, all expired items are removed from the source Room. + // More refactoring is required before this can be done locally instead - DrawableRoomPlaylist is currently directly bound to the playlist to display items in the room. + if (!(Status.Value is RoomStatusEnded)) + other.Playlist.RemoveAll(i => i.Expired); + if (!Playlist.SequenceEqual(other.Playlist)) { Playlist.Clear(); diff --git a/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs b/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs index 3f728a5417..ba3e3c6349 100644 --- a/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs +++ b/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs @@ -7,11 +7,11 @@ namespace osu.Game.Online.Rooms { public class ShowPlaylistUserScoreRequest : APIRequest { - private readonly int roomId; - private readonly int playlistItemId; + private readonly long roomId; + private readonly long playlistItemId; private readonly long userId; - public ShowPlaylistUserScoreRequest(int roomId, int playlistItemId, long userId) + public ShowPlaylistUserScoreRequest(long roomId, long playlistItemId, long userId) { this.roomId = roomId; this.playlistItemId = playlistItemId; diff --git a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs index 5a78b9fabd..9e432fa99e 100644 --- a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs @@ -11,12 +11,12 @@ namespace osu.Game.Online.Rooms { public class SubmitRoomScoreRequest : APIRequest { - private readonly int scoreId; - private readonly int roomId; - private readonly int playlistItemId; + private readonly long scoreId; + private readonly long roomId; + private readonly long playlistItemId; private readonly ScoreInfo scoreInfo; - public SubmitRoomScoreRequest(int scoreId, int roomId, int playlistItemId, ScoreInfo scoreInfo) + public SubmitRoomScoreRequest(long scoreId, long roomId, long playlistItemId, ScoreInfo scoreInfo) { this.scoreId = scoreId; this.roomId = roomId; diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs index b95e3f1297..3a586874fe 100644 --- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs +++ b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs @@ -8,13 +8,9 @@ using System.Linq; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Replays.Legacy; @@ -34,7 +30,14 @@ namespace osu.Game.Online.Spectator /// public const double TIME_BETWEEN_SENDS = 200; - private HubConnection connection; + private readonly string endpoint; + + [CanBeNull] + private IHubClientConnector connector; + + private readonly IBindable isConnected = new BindableBool(); + + private HubConnection connection => connector?.CurrentConnection; private readonly List watchingUsers = new List(); @@ -44,13 +47,6 @@ namespace osu.Game.Online.Spectator private readonly BindableList playingUsers = new BindableList(); - private readonly IBindable apiState = new Bindable(); - - private bool isConnected; - - [Resolved] - private IAPIProvider api { get; set; } - [CanBeNull] private IBeatmap currentBeatmap; @@ -82,85 +78,32 @@ namespace osu.Game.Online.Spectator /// public event Action OnUserFinishedPlaying; - private readonly string endpoint; - public SpectatorStreamingClient(EndpointConfiguration endpoints) { endpoint = endpoints.SpectatorEndpointUrl; } [BackgroundDependencyLoader] - private void load() + private void load(IAPIProvider api) { - apiState.BindTo(api.State); - apiState.BindValueChanged(apiStateChanged, true); - } + connector = api.GetHubConnector(nameof(SpectatorStreamingClient), endpoint); - private void apiStateChanged(ValueChangedEvent state) - { - switch (state.NewValue) + if (connector != null) { - case APIState.Failing: - case APIState.Offline: - connection?.StopAsync(); - connection = null; - break; - - case APIState.Online: - Task.Run(Connect); - break; - } - } - - protected virtual async Task Connect() - { - if (connection != null) - return; - - var builder = new HubConnectionBuilder() - .WithUrl(endpoint, options => { options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); }); - - if (RuntimeInfo.SupportsJIT) - builder.AddMessagePackProtocol(); - else - { - // eventually we will precompile resolvers for messagepack, but this isn't working currently - // see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308. - builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }); - } - - connection = builder.Build(); - // until strong typed client support is added, each method must be manually bound (see https://github.com/dotnet/aspnetcore/issues/15198) - connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); - connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); - connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); - - connection.Closed += async ex => - { - isConnected = false; - playingUsers.Clear(); - - if (ex != null) + connector.ConfigureConnection = connection => { - Logger.Log($"Spectator client lost connection: {ex}", LoggingTarget.Network); - await tryUntilConnected(); - } - }; + // until strong typed client support is added, each method must be manually bound + // (see https://github.com/dotnet/aspnetcore/issues/15198) + connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); + connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); + connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); + }; - await tryUntilConnected(); - - async Task tryUntilConnected() - { - Logger.Log("Spectator client connecting...", LoggingTarget.Network); - - while (api.State.Value == APIState.Online) + isConnected.BindTo(connector.IsConnected); + isConnected.BindValueChanged(connected => { - try + if (connected.NewValue) { - // reconnect on any failure - await connection.StartAsync(); - Logger.Log("Spectator client connected!", LoggingTarget.Network); - // get all the users that were previously being watched int[] users; @@ -170,25 +113,19 @@ namespace osu.Game.Online.Spectator watchingUsers.Clear(); } - // success - isConnected = true; - - // resubscribe to watched users + // resubscribe to watched users. foreach (var userId in users) WatchUser(userId); // re-send state in case it wasn't received if (isPlaying) beginPlaying(); - - break; } - catch (Exception e) + else { - Logger.Log($"Spectator client connection error: {e}", LoggingTarget.Network); - await Task.Delay(5000); + playingUsers.Clear(); } - } + }, true); } } @@ -240,14 +177,14 @@ namespace osu.Game.Online.Spectator { Debug.Assert(isPlaying); - if (!isConnected) return; + if (!isConnected.Value) return; connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState); } public void SendFrames(FrameDataBundle data) { - if (!isConnected) return; + if (!isConnected.Value) return; lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data); } @@ -257,7 +194,7 @@ namespace osu.Game.Online.Spectator isPlaying = false; currentBeatmap = null; - if (!isConnected) return; + if (!isConnected.Value) return; connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState); } @@ -271,7 +208,7 @@ namespace osu.Game.Online.Spectator watchingUsers.Add(userId); - if (!isConnected) + if (!isConnected.Value) return; } @@ -284,7 +221,7 @@ namespace osu.Game.Online.Spectator { watchingUsers.Remove(userId); - if (!isConnected) + if (!isConnected.Value) return; } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 1a1f7bd233..771bcd2310 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -28,7 +28,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Collections; @@ -52,6 +51,7 @@ using osu.Game.Updater; using osu.Game.Utils; using LogLevel = osu.Framework.Logging.LogLevel; using osu.Game.Database; +using osu.Game.IO; namespace osu.Game { @@ -88,7 +88,7 @@ namespace osu.Game protected SentryLogger SentryLogger; - public virtual Storage GetStorageForStableInstall() => null; + public virtual StableStorage GetStorageForStableInstall() => null; public float ToolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0); @@ -383,7 +383,7 @@ namespace osu.Game Ruleset.Value = selection.Ruleset; Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection); - }, validScreens: new[] { typeof(PlaySongSelect) }); + }, validScreens: new[] { typeof(SongSelect) }); } /// @@ -778,7 +778,7 @@ namespace osu.Game if (recentLogCount < short_term_display_limit) { - Schedule(() => notifications.Post(new SimpleNotification + 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), diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 20d88d33f2..3d24f245f9 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -31,6 +31,7 @@ using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Online; +using osu.Game.Online.Chat; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Overlays; @@ -98,7 +99,14 @@ namespace osu.Game [Cached(typeof(IBindable))] protected readonly Bindable Ruleset = new Bindable(); - // todo: move this to SongSelect once Screen has the ability to unsuspend. + /// + /// The current mod selection for the local user. + /// + /// + /// If a mod select overlay is present, mod instances set to this value are not guaranteed to remain as the provided instance and will be overwritten by a copy. + /// In such a case, changes to settings of a mod will *not* propagate after a mod is added to this collection. + /// As such, all settings should be finalised before adding a mod to this collection. + /// [Cached] [Cached(typeof(IBindable>))] protected readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); @@ -148,6 +156,13 @@ namespace osu.Game protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager(); + /// + /// The maximum volume at which audio tracks should playback. This can be set lower than 1 to create some head-room for sound effects. + /// + internal const double GLOBAL_TRACK_VOLUME_ADJUST = 0.5; + + private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(GLOBAL_TRACK_VOLUME_ADJUST); + [BackgroundDependencyLoader] private void load() { @@ -216,7 +231,9 @@ namespace osu.Game EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); - dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints)); + MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; + + dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient(endpoints)); dependencies.CacheAs(multiplayerClient = new MultiplayerClient(endpoints)); @@ -271,9 +288,10 @@ namespace osu.Game RegisterImportHandler(ScoreManager); RegisterImportHandler(SkinManager); - // tracks play so loud our samples can't keep up. - // this adds a global reduction of track volume for the time being. - Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, new BindableDouble(0.8)); + // drop track volume game-wide to leave some head-room for UI effects / samples. + // this means that for the time being, gameplay sample playback is louder relative to the audio track, compared to stable. + // we may want to revisit this if users notice or complain about the difference (consider this a bit of a trial). + Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, globalTrackVolumeAdjust); Beatmap = new NonNullableBindable(defaultBeatmap); diff --git a/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs b/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs index 6a2f2e4569..ca94078401 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels public DownloadProgressBar(BeatmapSetInfo beatmapSet) : base(beatmapSet) { - AddInternal(progressBar = new InteractionDisabledProgressBar + AddInternal(progressBar = new ProgressBar(false) { Height = 0, Alpha = 0, @@ -64,11 +64,5 @@ namespace osu.Game.Overlays.BeatmapListing.Panels } }, true); } - - private class InteractionDisabledProgressBar : ProgressBar - { - public override bool HandlePositionalInput => false; - public override bool HandleNonPositionalInput => false; - } } } diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 698984b306..5df7a4650e 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -15,98 +15,82 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.BeatmapListing.Panels; using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays { - public class BeatmapListingOverlay : FullscreenOverlay + public class BeatmapListingOverlay : OnlineOverlay { [Resolved] private PreviewTrackManager previewTrackManager { get; set; } private Drawable currentContent; - private LoadingLayer loadingLayer; private Container panelTarget; private FillFlowContainer foundContent; private NotFoundDrawable notFoundContent; - - private OverlayScrollContainer resultScrollContainer; + private BeatmapListingFilterControl filterControl; public BeatmapListingOverlay() - : base(OverlayColourScheme.Blue, new BeatmapListingHeader()) + : base(OverlayColourScheme.Blue) { } - private BeatmapListingFilterControl filterControl; - [BackgroundDependencyLoader] private void load() { - Children = new Drawable[] + Child = new FillFlowContainer { - new Box + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background6 - }, - resultScrollContainer = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new ReverseChildIDFillFlowContainer + filterControl = new BeatmapListingFilterControl + { + TypingStarted = onTypingStarted, + SearchStarted = onSearchStarted, + SearchFinished = onSearchFinished, + }, + new Container { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, Children = new Drawable[] { - Header, - filterControl = new BeatmapListingFilterControl + new Box { - TypingStarted = onTypingStarted, - SearchStarted = onSearchStarted, - SearchFinished = onSearchFinished, + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background4, }, - new Container + panelTarget = new Container { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 20 }, Children = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background4, - }, - panelTarget = new Container - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 20 }, - Children = new Drawable[] - { - foundContent = new FillFlowContainer(), - notFoundContent = new NotFoundDrawable(), - } - } - }, - }, - } + foundContent = new FillFlowContainer(), + notFoundContent = new NotFoundDrawable(), + } + } + }, }, - }, - loadingLayer = new LoadingLayer(true) + } }; } + protected override BeatmapListingHeader CreateHeader() => new BeatmapListingHeader(); + + protected override Color4 BackgroundColour => ColourProvider.Background6; + private void onTypingStarted() { // temporary until the textbox/header is updated to always stay on screen. - resultScrollContainer.ScrollToStart(); + ScrollFlow.ScrollToStart(); } protected override void OnFocus(FocusEvent e) @@ -125,7 +109,7 @@ namespace osu.Game.Overlays previewTrackManager.StopAnyPlaying(this); if (panelTarget.Any()) - loadingLayer.Show(); + Loading.Show(); } private Task panelLoadDelegate; @@ -173,7 +157,7 @@ namespace osu.Game.Overlays private void addContentToPlaceholder(Drawable content) { - loadingLayer.Hide(); + Loading.Hide(); lastFetchDisplayedTime = Time.Current; if (content == currentContent) @@ -267,7 +251,7 @@ namespace osu.Game.Overlays bool shouldShowMore = panelLoadDelegate?.IsCompleted != false && Time.Current - lastFetchDisplayedTime > time_between_fetches - && (resultScrollContainer.ScrollableExtent > 0 && resultScrollContainer.IsScrolledToEnd(pagination_scroll_distance)); + && (ScrollFlow.ScrollableExtent > 0 && ScrollFlow.IsScrolledToEnd(pagination_scroll_distance)); if (shouldShowMore) filterControl.FetchNextPage(); diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index c16ec339bb..bdb3715e73 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -6,20 +6,19 @@ 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.Events; using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests; using osu.Game.Overlays.BeatmapSet; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Overlays.Comments; using osu.Game.Rulesets; using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays { - public class BeatmapSetOverlay : FullscreenOverlay + public class BeatmapSetOverlay : OnlineOverlay { public const float X_PADDING = 40; public const float Y_PADDING = 25; @@ -33,55 +32,27 @@ namespace osu.Game.Overlays // receive input outside our bounds so we can trigger a close event on ourselves. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - private readonly Box background; - public BeatmapSetOverlay() - : base(OverlayColourScheme.Blue, new BeatmapSetHeader()) + : base(OverlayColourScheme.Blue) { - OverlayScrollContainer scroll; Info info; CommentsSection comments; - Children = new Drawable[] + Child = new FillFlowContainer { - background = new Box + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both - }, - scroll = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new ReverseChildIDFillFlowContainer + info = new Info(), + new ScoresContainer { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Children = new[] - { - new BeatmapSetLayoutSection - { - Child = new ReverseChildIDFillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header, - info = new Info() - } - }, - }, - new ScoresContainer - { - Beatmap = { BindTarget = Header.HeaderContent.Picker.Beatmap } - }, - comments = new CommentsSection() - }, + Beatmap = { BindTarget = Header.HeaderContent.Picker.Beatmap } }, - }, + comments = new CommentsSection() + } }; Header.BeatmapSet.BindTo(beatmapSet); @@ -91,16 +62,13 @@ namespace osu.Game.Overlays Header.HeaderContent.Picker.Beatmap.ValueChanged += b => { info.Beatmap = b.NewValue; - - scroll.ScrollToStart(); + ScrollFlow.ScrollToStart(); }; } - [BackgroundDependencyLoader] - private void load() - { - background.Colour = ColourProvider.Background6; - } + protected override BeatmapSetHeader CreateHeader() => new BeatmapSetHeader(); + + protected override Color4 BackgroundColour => ColourProvider.Background6; protected override void PopOutComplete() { diff --git a/osu.Game/Overlays/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index c7e9a86fa4..537dd00727 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -11,68 +11,33 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Changelog; +using osuTK.Graphics; namespace osu.Game.Overlays { - public class ChangelogOverlay : FullscreenOverlay + public class ChangelogOverlay : OnlineOverlay { public readonly Bindable Current = new Bindable(); - private Container content; - - private SampleChannel sampleBack; + private Sample sampleBack; private List builds; protected List Streams; public ChangelogOverlay() - : base(OverlayColourScheme.Purple, new ChangelogHeader()) + : base(OverlayColourScheme.Purple, false) { } [BackgroundDependencyLoader] private void load(AudioManager audio) { - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background4, - }, - new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new ReverseChildIDFillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header.With(h => - { - h.ListingSelected = ShowListing; - h.Build.BindTarget = Current; - }), - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - } - }, - }, - }, - }; + Header.Build.BindTarget = Current; sampleBack = audio.Samples.Get(@"UI/generic-select-soft"); @@ -85,6 +50,13 @@ namespace osu.Game.Overlays }); } + protected override ChangelogHeader CreateHeader() => new ChangelogHeader + { + ListingSelected = ShowListing, + }; + + protected override Color4 BackgroundColour => ColourProvider.Background4; + public void ShowListing() { Current.Value = null; @@ -198,16 +170,16 @@ namespace osu.Game.Overlays private void loadContent(ChangelogContent newContent) { - content.FadeTo(0.2f, 300, Easing.OutQuint); + Content.FadeTo(0.2f, 300, Easing.OutQuint); loadContentCancellation?.Cancel(); LoadComponentAsync(newContent, c => { - content.FadeIn(300, Easing.OutQuint); + Content.FadeIn(300, Easing.OutQuint); c.BuildSelected = ShowBuild; - content.Child = c; + Child = c; }, (loadContentCancellation = new CancellationTokenSource()).Token); } } diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 8bc7e21047..28f2287514 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -24,6 +24,7 @@ using osu.Game.Overlays.Chat.Tabs; using osuTK.Input; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Game.Online; namespace osu.Game.Overlays { @@ -118,40 +119,47 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.Both, }, - currentChannelContainer = new Container + new OnlineViewContainer("Sign in to chat") { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Bottom = textbox_height - }, - }, - new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = textbox_height, - Padding = new MarginPadding - { - Top = padding * 2, - Bottom = padding * 2, - Left = ChatLine.LEFT_PADDING + padding * 2, - Right = padding * 2, - }, Children = new Drawable[] { - textbox = new FocusedTextBox + currentChannelContainer = new Container { RelativeSizeAxes = Axes.Both, - Height = 1, - PlaceholderText = "type your message", - ReleaseFocusOnCommit = false, - HoldFocus = true, - } - } - }, - loading = new LoadingSpinner(), + Padding = new MarginPadding + { + Bottom = textbox_height + }, + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = textbox_height, + Padding = new MarginPadding + { + Top = padding * 2, + Bottom = padding * 2, + Left = ChatLine.LEFT_PADDING + padding * 2, + Right = padding * 2, + }, + Children = new Drawable[] + { + textbox = new FocusedTextBox + { + RelativeSizeAxes = Axes.Both, + Height = 1, + PlaceholderText = "type your message", + ReleaseFocusOnCommit = false, + HoldFocus = true, + } + } + }, + loading = new LoadingSpinner(), + }, + } } }, tabsArea = new TabsArea diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 31aa41e967..7c47ac655f 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -9,7 +9,6 @@ using osuTK; using osu.Game.Online.API.Requests.Responses; using osu.Game.Users.Drawables; using osu.Game.Graphics.Containers; -using osu.Game.Utils; using osu.Framework.Graphics.Cursor; using osu.Framework.Bindables; using System.Linq; @@ -245,11 +244,32 @@ namespace osu.Game.Overlays.Comments if (Comment.EditedAt.HasValue) { - info.Add(new OsuSpriteText + var font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); + var colour = colourProvider.Foreground1; + + info.Add(new FillFlowContainer { - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), - Text = $@"edited {HumanizerUtils.Humanize(Comment.EditedAt.Value)} by {Comment.EditedUser.Username}", - Colour = colourProvider.Foreground1 + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Font = font, + Text = "edited ", + Colour = colour + }, + new DrawableDate(Comment.EditedAt.Value) + { + Font = font, + Colour = colour + }, + new OsuSpriteText + { + Font = font, + Text = $@" by {Comment.EditedUser.Username}", + Colour = colour + }, + } }); } diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs index 03c320debe..83ad8faf1c 100644 --- a/osu.Game/Overlays/DashboardOverlay.cs +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -2,155 +2,35 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Threading; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; using osu.Game.Overlays.Dashboard; using osu.Game.Overlays.Dashboard.Friends; namespace osu.Game.Overlays { - public class DashboardOverlay : FullscreenOverlay + public class DashboardOverlay : TabbableOnlineOverlay { - private CancellationTokenSource cancellationToken; - - private Container content; - private LoadingLayer loading; - private OverlayScrollContainer scrollFlow; - public DashboardOverlay() - : base(OverlayColourScheme.Purple, new DashboardOverlayHeader - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Depth = -float.MaxValue - }) + : base(OverlayColourScheme.Purple) { } - private readonly IBindable apiState = new Bindable(); + protected override DashboardOverlayHeader CreateHeader() => new DashboardOverlayHeader(); - [BackgroundDependencyLoader] - private void load(IAPIProvider api) + protected override void CreateDisplayToLoad(DashboardOverlayTabs tab) { - apiState.BindTo(api.State); - apiState.BindValueChanged(onlineStateChanged, true); - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background5 - }, - scrollFlow = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header, - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - } - } - } - }, - loading = new LoadingLayer(true), - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Header.Current.BindValueChanged(onTabChanged); - } - - private bool displayUpdateRequired = true; - - protected override void PopIn() - { - base.PopIn(); - - // We don't want to create a new display on every call, only when exiting from fully closed state. - if (displayUpdateRequired) - { - Header.Current.TriggerChange(); - displayUpdateRequired = false; - } - } - - protected override void PopOutComplete() - { - base.PopOutComplete(); - loadDisplay(Empty()); - displayUpdateRequired = true; - } - - private void loadDisplay(Drawable display) - { - scrollFlow.ScrollToStart(); - - LoadComponentAsync(display, loaded => - { - if (API.IsLoggedIn) - loading.Hide(); - - content.Child = loaded; - }, (cancellationToken = new CancellationTokenSource()).Token); - } - - private void onTabChanged(ValueChangedEvent tab) - { - cancellationToken?.Cancel(); - loading.Show(); - - if (!API.IsLoggedIn) - { - loadDisplay(Empty()); - return; - } - - switch (tab.NewValue) + switch (tab) { case DashboardOverlayTabs.Friends: - loadDisplay(new FriendDisplay()); + LoadDisplay(new FriendDisplay()); break; case DashboardOverlayTabs.CurrentlyPlaying: - loadDisplay(new CurrentlyPlayingDisplay()); + LoadDisplay(new CurrentlyPlayingDisplay()); break; default: - throw new NotImplementedException($"Display for {tab.NewValue} tab is not implemented"); + throw new NotImplementedException($"Display for {tab} tab is not implemented"); } } - - private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => - { - if (State.Value == Visibility.Hidden) - return; - - Header.Current.TriggerChange(); - }); - - protected override void Dispose(bool isDisposing) - { - cancellationToken?.Cancel(); - base.Dispose(isDisposing); - } } } diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 9f9dbdbaf1..4cc17a4c14 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -14,6 +14,9 @@ namespace osu.Game.Overlays { private readonly Container dialogContainer; + protected override string PopInSampleName => "UI/dialog-pop-in"; + protected override string PopOutSampleName => "UI/dialog-pop-out"; + public PopupDialog CurrentDialog { get; private set; } public DialogOverlay() diff --git a/osu.Game/Overlays/FullscreenOverlay.cs b/osu.Game/Overlays/FullscreenOverlay.cs index 6f56d95929..735f0bcbd4 100644 --- a/osu.Game/Overlays/FullscreenOverlay.cs +++ b/osu.Game/Overlays/FullscreenOverlay.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 JetBrains.Annotations; 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.Game.Graphics.Containers; using osu.Game.Online.API; using osuTK.Graphics; @@ -15,21 +17,27 @@ namespace osu.Game.Overlays public abstract class FullscreenOverlay : WaveOverlayContainer, INamedOverlayComponent where T : OverlayHeader { - public virtual string IconTexture => Header?.Title.IconTexture ?? string.Empty; - public virtual string Title => Header?.Title.Title ?? string.Empty; - public virtual string Description => Header?.Title.Description ?? string.Empty; + public virtual string IconTexture => Header.Title.IconTexture ?? string.Empty; + public virtual string Title => Header.Title.Title ?? string.Empty; + public virtual string Description => Header.Title.Description ?? string.Empty; public T Header { get; } + protected virtual Color4 BackgroundColour => ColourProvider.Background5; + [Resolved] protected IAPIProvider API { get; private set; } [Cached] protected readonly OverlayColourProvider ColourProvider; - protected FullscreenOverlay(OverlayColourScheme colourScheme, T header) + protected override Container Content => content; + + private readonly Container content; + + protected FullscreenOverlay(OverlayColourScheme colourScheme) { - Header = header; + Header = CreateHeader(); ColourProvider = new OverlayColourProvider(colourScheme); @@ -47,6 +55,19 @@ namespace osu.Game.Overlays Type = EdgeEffectType.Shadow, Radius = 10 }; + + base.Content.AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = BackgroundColour + }, + content = new Container + { + RelativeSizeAxes = Axes.Both + } + }); } [BackgroundDependencyLoader] @@ -58,6 +79,9 @@ namespace osu.Game.Overlays Waves.FourthWaveColour = ColourProvider.Dark3; } + [NotNull] + protected abstract T CreateHeader(); + public override void Show() { if (State.Value == Visibility.Visible) diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index 4425c2f168..0feae16b68 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -39,7 +39,7 @@ namespace osu.Game.Overlays private readonly Sprite innerSpin, outerSpin; private DrawableMedal drawableMedal; - private SampleChannel getSample; + private Sample getSample; private readonly Container content; diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs index 8e0d1f5bbd..5e3733cd5e 100644 --- a/osu.Game/Overlays/Mods/ModButton.cs +++ b/osu.Game/Overlays/Mods/ModButton.cs @@ -46,8 +46,9 @@ namespace osu.Game.Overlays.Mods /// Change the selected mod index of this button. /// /// The new index. + /// Whether any settings applied to the mod should be reset on selection. /// Whether the selection changed. - private bool changeSelectedIndex(int newIndex) + private bool changeSelectedIndex(int newIndex, bool resetSettings = true) { if (newIndex == selectedIndex) return false; @@ -69,6 +70,9 @@ namespace osu.Game.Overlays.Mods Mod newSelection = SelectedMod ?? Mods[0]; + if (resetSettings) + newSelection.ResetSettingsToDefaults(); + Schedule(() => { if (beforeSelected != Selected) @@ -209,11 +213,17 @@ namespace osu.Game.Overlays.Mods Deselect(); } - public bool SelectAt(int index) + /// + /// Select the mod at the provided index. + /// + /// The index to select. + /// Whether any settings applied to the mod should be reset on selection. + /// Whether the selection changed. + public bool SelectAt(int index, bool resetSettings = true) { if (!Mods[index].HasImplementation) return false; - changeSelectedIndex(index); + changeSelectedIndex(index, resetSettings); return true; } diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index ecbcba7ad3..c3e56abd05 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -197,8 +197,11 @@ namespace osu.Game.Overlays.Mods continue; var buttonMod = button.Mods[index]; + + // as this is likely coming from an external change, ensure the settings of the mod are in sync. buttonMod.CopyFrom(mod); - button.SelectAt(index); + + button.SelectAt(index, false); return; } diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 93fe693937..eef91deb4c 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -51,6 +51,11 @@ namespace osu.Game.Overlays.Mods /// protected virtual bool Stacked => true; + /// + /// Whether configurable s can be configured by the local user. + /// + protected virtual bool AllowConfiguration => true; + [NotNull] private Func isValidMod = m => true; @@ -82,7 +87,7 @@ namespace osu.Game.Overlays.Mods private const float content_width = 0.8f; private const float footer_button_spacing = 20; - private SampleChannel sampleOn, sampleOff; + private Sample sampleOn, sampleOff; protected ModSelectOverlay() { @@ -300,6 +305,7 @@ namespace osu.Game.Overlays.Mods Text = "Customisation", Action = () => ModSettingsContainer.ToggleVisibility(), Enabled = { Value = false }, + Alpha = AllowConfiguration ? 1 : 0, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, }, @@ -372,7 +378,10 @@ namespace osu.Game.Overlays.Mods base.LoadComplete(); availableMods.BindValueChanged(_ => updateAvailableMods(), true); - SelectedMods.BindValueChanged(_ => updateSelectedButtons(), true); + + // intentionally bound after the above line to avoid a potential update feedback cycle. + // i haven't actually observed this happening but as updateAvailableMods() changes the selection it is plausible. + SelectedMods.BindValueChanged(_ => updateSelectedButtons()); } protected override void PopOut() @@ -479,10 +488,10 @@ namespace osu.Game.Overlays.Mods foreach (var section in ModSectionsContainer.Children) section.UpdateSelectedButtons(selectedMods); - updateMods(); + updateMultiplier(); } - private void updateMods() + private void updateMultiplier() { var multiplier = 1.0; @@ -509,7 +518,8 @@ namespace osu.Game.Overlays.Mods OnModSelected(selectedMod); - if (selectedMod.RequiresConfiguration) ModSettingsContainer.Show(); + if (selectedMod.RequiresConfiguration && AllowConfiguration) + ModSettingsContainer.Show(); } else { diff --git a/osu.Game/Overlays/Mods/ModSettingsContainer.cs b/osu.Game/Overlays/Mods/ModSettingsContainer.cs index 1c57ff54ad..64d65cab3b 100644 --- a/osu.Game/Overlays/Mods/ModSettingsContainer.cs +++ b/osu.Game/Overlays/Mods/ModSettingsContainer.cs @@ -52,6 +52,7 @@ namespace osu.Game.Overlays.Mods new OsuScrollContainer { RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, Child = modSettingsContent = new FillFlowContainer { Anchor = Anchor.TopCentre, diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index 5820d405d4..5beb285216 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -2,67 +2,22 @@ // 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.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.News; using osu.Game.Overlays.News.Displays; namespace osu.Game.Overlays { - public class NewsOverlay : FullscreenOverlay + public class NewsOverlay : OnlineOverlay { private readonly Bindable article = new Bindable(null); - private Container content; - private LoadingLayer loading; - private OverlayScrollContainer scrollFlow; - public NewsOverlay() - : base(OverlayColourScheme.Purple, new NewsHeader()) + : base(OverlayColourScheme.Purple, false) { } - [BackgroundDependencyLoader] - private void load() - { - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background5, - }, - scrollFlow = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header.With(h => - { - h.ShowFrontPage = ShowFrontPage; - }), - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - } - }, - }, - }, - loading = new LoadingLayer(true), - }; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -71,6 +26,11 @@ namespace osu.Game.Overlays article.BindValueChanged(onArticleChanged); } + protected override NewsHeader CreateHeader() => new NewsHeader + { + ShowFrontPage = ShowFrontPage + }; + private bool displayUpdateRequired = true; protected override void PopIn() @@ -107,7 +67,7 @@ namespace osu.Game.Overlays private void onArticleChanged(ValueChangedEvent e) { cancellationToken?.Cancel(); - loading.Show(); + Loading.Show(); if (e.NewValue == null) { @@ -122,11 +82,11 @@ namespace osu.Game.Overlays protected void LoadDisplay(Drawable display) { - scrollFlow.ScrollToStart(); + ScrollFlow.ScrollToStart(); LoadComponentAsync(display, loaded => { - content.Child = loaded; - loading.Hide(); + Child = loaded; + Loading.Hide(); }, (cancellationToken = new CancellationTokenSource()).Token); } diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index 2dc6b39a92..d1a97c74b2 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -3,6 +3,8 @@ 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.Colour; @@ -40,6 +42,11 @@ namespace osu.Game.Overlays.Notifications /// public virtual bool DisplayOnTop => true; + private Sample samplePopIn; + private Sample samplePopOut; + protected virtual string PopInSampleName => "UI/notification-pop-in"; + protected virtual string PopOutSampleName => "UI/overlay-pop-out"; // TODO: replace with a unique sample? + protected NotificationLight Light; private readonly CloseButton closeButton; protected Container IconContent; @@ -107,7 +114,7 @@ namespace osu.Game.Overlays.Notifications closeButton = new CloseButton { Alpha = 0, - Action = Close, + Action = () => Close(), Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Margin = new MarginPadding @@ -120,6 +127,13 @@ namespace osu.Game.Overlays.Notifications }); } + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + samplePopIn = audio.Samples.Get(PopInSampleName); + samplePopOut = audio.Samples.Get(PopOutSampleName); + } + protected override bool OnHover(HoverEvent e) { closeButton.FadeIn(75); @@ -143,6 +157,9 @@ namespace osu.Game.Overlays.Notifications protected override void LoadComplete() { base.LoadComplete(); + + samplePopIn?.Play(); + this.FadeInFromZero(200); NotificationContent.MoveToX(DrawSize.X); NotificationContent.MoveToX(0, 500, Easing.OutQuint); @@ -150,12 +167,15 @@ namespace osu.Game.Overlays.Notifications public bool WasClosed; - public virtual void Close() + public virtual void Close(bool playSound = true) { if (WasClosed) return; WasClosed = true; + if (playSound) + samplePopOut?.Play(); + Closed?.Invoke(); this.FadeOut(100); Expire(); diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index c2a958b65e..38ba712254 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.cs @@ -109,7 +109,12 @@ namespace osu.Game.Overlays.Notifications private void clearAll() { - notifications.Children.ForEach(c => c.Close()); + bool first = true; + notifications.Children.ForEach(c => + { + c.Close(first); + first = false; + }); } protected override void Update() diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 3105ecd742..703c14af2b 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -150,12 +150,12 @@ namespace osu.Game.Overlays.Notifications colourCancelled = colours.Red; } - public override void Close() + public override void Close(bool playSound = true) { switch (State) { case ProgressNotificationState.Cancelled: - base.Close(); + base.Close(playSound); break; case ProgressNotificationState.Active: diff --git a/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs b/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs new file mode 100644 index 0000000000..13c9c5a02d --- /dev/null +++ b/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Overlays.Notifications +{ + public class SimpleErrorNotification : SimpleNotification + { + protected override string PopInSampleName => "UI/error-notification-pop-in"; + + public SimpleErrorNotification() + { + Icon = FontAwesome.Solid.Bomb; + } + } +} diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 9beb859f28..2866d2ad6d 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -51,6 +51,9 @@ namespace osu.Game.Overlays private Container dragContainer; private Container playerContainer; + protected override string PopInSampleName => "UI/now-playing-pop-in"; + protected override string PopOutSampleName => "UI/now-playing-pop-out"; + /// /// Provide a source for the toolbar height. /// @@ -84,11 +87,6 @@ namespace osu.Game.Overlays AutoSizeAxes = Axes.Y, Children = new Drawable[] { - playlist = new PlaylistOverlay - { - RelativeSizeAxes = Axes.X, - Y = player_height + 10, - }, playerContainer = new Container { RelativeSizeAxes = Axes.X, @@ -171,7 +169,7 @@ namespace osu.Game.Overlays Anchor = Anchor.CentreRight, Position = new Vector2(-bottom_black_area_height / 2, 0), Icon = FontAwesome.Solid.Bars, - Action = () => playlist.ToggleVisibility(), + Action = togglePlaylist }, } }, @@ -191,13 +189,35 @@ namespace osu.Game.Overlays }; } + private void togglePlaylist() + { + if (playlist == null) + { + LoadComponentAsync(playlist = new PlaylistOverlay + { + RelativeSizeAxes = Axes.X, + Y = player_height + 10, + }, _ => + { + dragContainer.Add(playlist); + + playlist.BeatmapSets.BindTo(musicController.BeatmapSets); + playlist.State.BindValueChanged(s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); + + togglePlaylist(); + }); + + return; + } + + if (!beatmap.Disabled) + playlist.ToggleVisibility(); + } + protected override void LoadComplete() { base.LoadComplete(); - playlist.BeatmapSets.BindTo(musicController.BeatmapSets); - playlist.State.BindValueChanged(s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); - beatmap.BindDisabledChanged(beatmapDisabledChanged, true); musicController.TrackChanged += trackChanged; @@ -306,7 +326,7 @@ namespace osu.Game.Overlays private void beatmapDisabledChanged(bool disabled) { if (disabled) - playlist.Hide(); + playlist?.Hide(); prevButton.Enabled.Value = !disabled; nextButton.Enabled.Value = !disabled; @@ -411,6 +431,11 @@ namespace osu.Game.Overlays private class HoverableProgressBar : ProgressBar { + public HoverableProgressBar() + : base(true) + { + } + protected override bool OnHover(HoverEvent e) { this.ResizeHeightTo(progress_height, 500, Easing.OutQuint); diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 8e8a99a0a7..51214fe460 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -3,6 +3,8 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -19,6 +21,13 @@ namespace osu.Game.Overlays.OSD { private const int lights_bottom_margin = 40; + private readonly int optionCount; + private readonly int selectedOption = -1; + + private Sample sampleOn; + private Sample sampleOff; + private Sample sampleChange; + public TrackedSettingToast(SettingDescription description) : base(description.Name, description.Value, description.Shortcut) { @@ -46,9 +55,6 @@ namespace osu.Game.Overlays.OSD } }; - int optionCount = 0; - int selectedOption = -1; - switch (description.RawValue) { case bool val: @@ -69,6 +75,34 @@ namespace osu.Game.Overlays.OSD optionLights.Add(new OptionLight { Glowing = i == selectedOption }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + if (optionCount == 1) + { + if (selectedOption == 0) + sampleOn?.Play(); + else + sampleOff?.Play(); + } + else + { + if (sampleChange == null) return; + + sampleChange.Frequency.Value = 1 + (double)selectedOption / (optionCount - 1) * 0.25f; + sampleChange.Play(); + } + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleOn = audio.Samples.Get("UI/osd-on"); + sampleOff = audio.Samples.Get("UI/osd-off"); + sampleChange = audio.Samples.Get("UI/osd-change"); + } + private class OptionLight : Container { private Color4 glowingColour, idleColour; diff --git a/osu.Game/Overlays/OnlineOverlay.cs b/osu.Game/Overlays/OnlineOverlay.cs new file mode 100644 index 0000000000..de33e4a1bc --- /dev/null +++ b/osu.Game/Overlays/OnlineOverlay.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; + +namespace osu.Game.Overlays +{ + public abstract class OnlineOverlay : FullscreenOverlay + where T : OverlayHeader + { + protected override Container Content => content; + + protected readonly OverlayScrollContainer ScrollFlow; + protected readonly LoadingLayer Loading; + private readonly Container content; + + protected OnlineOverlay(OverlayColourScheme colourScheme, bool requiresSignIn = true) + : base(colourScheme) + { + var mainContent = requiresSignIn + ? new OnlineViewContainer($"Sign in to view the {Header.Title.Title}") + : new Container(); + + mainContent.RelativeSizeAxes = Axes.Both; + + mainContent.AddRange(new Drawable[] + { + ScrollFlow = new OverlayScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + Header.With(h => h.Depth = float.MinValue), + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }, + Loading = new LoadingLayer(true) + }); + + base.Content.Add(mainContent); + } + } +} diff --git a/osu.Game/Overlays/RankingsOverlay.cs b/osu.Game/Overlays/RankingsOverlay.cs index 25350e310a..a093969115 100644 --- a/osu.Game/Overlays/RankingsOverlay.cs +++ b/osu.Game/Overlays/RankingsOverlay.cs @@ -4,96 +4,32 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Game.Overlays.Rankings; using osu.Game.Users; using osu.Game.Rulesets; using osu.Game.Online.API; -using System.Threading; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Rankings.Tables; namespace osu.Game.Overlays { - public class RankingsOverlay : FullscreenOverlay + public class RankingsOverlay : TabbableOnlineOverlay { protected Bindable Country => Header.Country; - protected Bindable Scope => Header.Current; - - private readonly OverlayScrollContainer scrollFlow; - private readonly Container contentContainer; - private readonly LoadingLayer loading; - private readonly Box background; - private APIRequest lastRequest; - private CancellationTokenSource cancellationToken; [Resolved] private IAPIProvider api { get; set; } - public RankingsOverlay() - : base(OverlayColourScheme.Green, new RankingsOverlayHeader - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Depth = -float.MaxValue - }) - { - loading = new LoadingLayer(true); - - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both - }, - scrollFlow = new OverlayScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - contentContainer = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Bottom = 10 } - }, - } - } - } - } - }, - loading - }; - } - - [BackgroundDependencyLoader] - private void load() - { - background.Colour = ColourProvider.Background5; - } - [Resolved] private Bindable ruleset { get; set; } + public RankingsOverlay() + : base(OverlayColourScheme.Green) + { + } + protected override void LoadComplete() { base.LoadComplete(); @@ -104,31 +40,33 @@ namespace osu.Game.Overlays { // if a country is requested, force performance scope. if (Country.Value != null) - Scope.Value = RankingsScope.Performance; + Header.Current.Value = RankingsScope.Performance; - Scheduler.AddOnce(loadNewContent); - }); - - Scope.BindValueChanged(_ => - { - // country filtering is only valid for performance scope. - if (Scope.Value != RankingsScope.Performance) - Country.Value = null; - - Scheduler.AddOnce(loadNewContent); + Scheduler.AddOnce(triggerTabChanged); }); ruleset.BindValueChanged(_ => { - if (Scope.Value == RankingsScope.Spotlights) + if (Header.Current.Value == RankingsScope.Spotlights) return; - Scheduler.AddOnce(loadNewContent); + Scheduler.AddOnce(triggerTabChanged); }); - - Scheduler.AddOnce(loadNewContent); } + protected override void OnTabChanged(RankingsScope tab) + { + // country filtering is only valid for performance scope. + if (Header.Current.Value != RankingsScope.Performance) + Country.Value = null; + + Scheduler.AddOnce(triggerTabChanged); + } + + private void triggerTabChanged() => base.OnTabChanged(Header.Current.Value); + + protected override RankingsOverlayHeader CreateHeader() => new RankingsOverlayHeader(); + public void ShowCountry(Country requested) { if (requested == null) @@ -139,22 +77,13 @@ namespace osu.Game.Overlays Country.Value = requested; } - public void ShowSpotlights() + protected override void CreateDisplayToLoad(RankingsScope tab) { - Scope.Value = RankingsScope.Spotlights; - Show(); - } - - private void loadNewContent() - { - loading.Show(); - - cancellationToken?.Cancel(); lastRequest?.Cancel(); - if (Scope.Value == RankingsScope.Spotlights) + if (Header.Current.Value == RankingsScope.Spotlights) { - loadContent(new SpotlightsLayout + LoadDisplay(new SpotlightsLayout { Ruleset = { BindTarget = ruleset } }); @@ -166,19 +95,19 @@ namespace osu.Game.Overlays if (request == null) { - loadContent(null); + LoadDisplay(Empty()); return; } - request.Success += () => Schedule(() => loadContent(createTableFromResponse(request))); - request.Failure += _ => Schedule(() => loadContent(null)); + request.Success += () => Schedule(() => LoadDisplay(createTableFromResponse(request))); + request.Failure += _ => Schedule(() => LoadDisplay(Empty())); api.Queue(request); } private APIRequest createScopedRequest() { - switch (Scope.Value) + switch (Header.Current.Value) { case RankingsScope.Performance: return new GetUserRankingsRequest(ruleset.Value, country: Country.Value?.FlagName); @@ -216,29 +145,9 @@ namespace osu.Game.Overlays return null; } - private void loadContent(Drawable content) - { - scrollFlow.ScrollToStart(); - - if (content == null) - { - contentContainer.Clear(); - loading.Hide(); - return; - } - - LoadComponentAsync(content, loaded => - { - loading.Hide(); - contentContainer.Child = loaded; - }, (cancellationToken = new CancellationTokenSource()).Token); - } - protected override void Dispose(bool isDisposing) { lastRequest?.Cancel(); - cancellationToken?.Cancel(); - base.Dispose(isDisposing); } } diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 278479e04f..af225889da 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -21,7 +21,7 @@ using osuTK; namespace osu.Game.Overlays.Settings { - public abstract class SettingsItem : Container, IFilterable, ISettingsItem, IHasCurrentValue + public abstract class SettingsItem : Container, IFilterable, ISettingsItem, IHasCurrentValue, IHasTooltip { protected abstract Drawable CreateControl(); @@ -37,6 +37,8 @@ namespace osu.Game.Overlays.Settings public bool ShowsDefaultIndicator = true; + public string TooltipText { get; set; } + public virtual string LabelText { get => labelText?.Text ?? string.Empty; diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index 7a5a586f67..f1270f750e 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -40,6 +40,8 @@ namespace osu.Game.Overlays private SeekLimitedSearchTextBox searchTextBox; + protected override string PopInSampleName => "UI/settings-pop-in"; + /// /// Provide a source for the toolbar height. /// diff --git a/osu.Game/Overlays/TabbableOnlineOverlay.cs b/osu.Game/Overlays/TabbableOnlineOverlay.cs new file mode 100644 index 0000000000..9ceab12d3d --- /dev/null +++ b/osu.Game/Overlays/TabbableOnlineOverlay.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.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; + +namespace osu.Game.Overlays +{ + public abstract class TabbableOnlineOverlay : OnlineOverlay + where THeader : TabControlOverlayHeader + { + private readonly IBindable apiState = new Bindable(); + + private CancellationTokenSource cancellationToken; + private bool displayUpdateRequired = true; + + protected TabbableOnlineOverlay(OverlayColourScheme colourScheme) + : base(colourScheme) + { + } + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Header.Current.BindValueChanged(tab => OnTabChanged(tab.NewValue)); + } + + protected override void PopIn() + { + base.PopIn(); + + // We don't want to create a new display on every call, only when exiting from fully closed state. + if (displayUpdateRequired) + { + Header.Current.TriggerChange(); + displayUpdateRequired = false; + } + } + + protected override void PopOutComplete() + { + base.PopOutComplete(); + LoadDisplay(Empty()); + displayUpdateRequired = true; + } + + protected void LoadDisplay(Drawable display) + { + ScrollFlow.ScrollToStart(); + + LoadComponentAsync(display, loaded => + { + Loading.Hide(); + + Child = loaded; + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + protected virtual void OnTabChanged(TEnum tab) + { + cancellationToken?.Cancel(); + Loading.Show(); + + if (!API.IsLoggedIn) + { + LoadDisplay(Empty()); + return; + } + + CreateDisplayToLoad(tab); + } + + protected abstract void CreateDisplayToLoad(TEnum tab); + + private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => + { + if (State.Value == Visibility.Hidden) + return; + + Header.Current.TriggerChange(); + }); + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 49b9c62d85..83f2bdf6cb 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -77,7 +77,7 @@ namespace osu.Game.Overlays.Toolbar private KeyBindingStore keyBindings { get; set; } protected ToolbarButton() - : base(HoverSampleSet.Loud) + : base(HoverSampleSet.Toolbar) { Width = Toolbar.HEIGHT; RelativeSizeAxes = Axes.Y; diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index 7f29545c2e..299a14b250 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -15,6 +15,7 @@ using osu.Game.Overlays.Profile; using osu.Game.Overlays.Profile.Sections; using osu.Game.Users; using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays { @@ -29,10 +30,14 @@ namespace osu.Game.Overlays public const float CONTENT_X_MARGIN = 70; public UserProfileOverlay() - : base(OverlayColourScheme.Pink, new ProfileHeader()) + : base(OverlayColourScheme.Pink) { } + protected override ProfileHeader CreateHeader() => new ProfileHeader(); + + protected override Color4 BackgroundColour => ColourProvider.Background6; + public void ShowUser(int userId) => ShowUser(new User { Id = userId }); public void ShowUser(User user, bool fetchOnline = true) @@ -72,12 +77,6 @@ namespace osu.Game.Overlays Origin = Anchor.TopCentre, }; - Add(new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background6 - }); - Add(sectionsContainer = new ProfileSectionsContainer { ExpandableHeader = Header, diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index 07accf8820..5b997bbd05 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -176,6 +176,7 @@ namespace osu.Game.Overlays.Volume } } }; + Bindable.ValueChanged += volume => { this.TransformTo("DisplayVolume", @@ -183,6 +184,7 @@ namespace osu.Game.Overlays.Volume 400, Easing.OutQuint); }; + bgProgress.Current.Value = 0.75f; } diff --git a/osu.Game/Overlays/WaveOverlayContainer.cs b/osu.Game/Overlays/WaveOverlayContainer.cs index d0fa9987d5..52ae4dbdbb 100644 --- a/osu.Game/Overlays/WaveOverlayContainer.cs +++ b/osu.Game/Overlays/WaveOverlayContainer.cs @@ -18,6 +18,8 @@ namespace osu.Game.Overlays protected override bool StartHidden => true; + protected override string PopInSampleName => "UI/wave-pop-in"; + protected WaveOverlayContainer() { AddInternal(Waves = new WaveContainer diff --git a/osu.Game/PerformFromMenuRunner.cs b/osu.Game/PerformFromMenuRunner.cs index 7999023998..a4179c94da 100644 --- a/osu.Game/PerformFromMenuRunner.cs +++ b/osu.Game/PerformFromMenuRunner.cs @@ -82,7 +82,9 @@ namespace osu.Game game?.CloseAllOverlays(false); // we may already be at the target screen type. - if (validScreens.Contains(current.GetType()) && !beatmap.Disabled) + var type = current.GetType(); + + if (validScreens.Any(t => t.IsAssignableFrom(type)) && !beatmap.Disabled) { finalAction(current); Cancel(); @@ -91,13 +93,14 @@ namespace osu.Game while (current != null) { - if (validScreens.Contains(current.GetType())) + if (validScreens.Any(t => t.IsAssignableFrom(type))) { current.MakeCurrent(); break; } current = current.GetParentScreen(); + type = current?.GetType(); } } diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index c0eb891f5e..bfff93e7c5 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects; @@ -45,6 +46,9 @@ namespace osu.Game.Rulesets.Edit { HitObject = hitObject; + // adding the default hit sample should be the case regardless of the ruleset. + HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_NORMAL)); + RelativeSizeAxes = Axes.Both; // This is required to allow the blueprint's position to be updated via OnMouseMove/Handle diff --git a/osu.Game/Rulesets/Mods/IApplicableToRate.cs b/osu.Game/Rulesets/Mods/IApplicableToRate.cs new file mode 100644 index 0000000000..f613867132 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableToRate.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. + +namespace osu.Game.Rulesets.Mods +{ + /// + /// Interface that should be implemented by mods that affect the track playback speed, + /// and in turn, values of the track rate. + /// + public interface IApplicableToRate : IApplicableToAudio + { + /// + /// Returns the playback rate at after this mod is applied. + /// + /// The time instant at which the playback rate is queried. + /// The playback rate before applying this mod. + /// The playback rate after applying this mod. + double ApplyToRate(double time, double rate = 1); + } +} diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 3a8717e678..2a11c92223 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -134,7 +134,7 @@ namespace osu.Game.Rulesets.Mods } /// - /// Copies mod setting values from into this instance. + /// Copies mod setting values from into this instance, overwriting all existing settings. /// /// The mod to copy properties from. public void CopyFrom(Mod source) @@ -147,9 +147,7 @@ namespace osu.Game.Rulesets.Mods var targetBindable = (IBindable)prop.GetValue(this); var sourceBindable = (IBindable)prop.GetValue(source); - // we only care about changes that have been made away from defaults. - if (!sourceBindable.IsDefault) - CopyAdjustedSetting(targetBindable, sourceBindable); + CopyAdjustedSetting(targetBindable, sourceBindable); } } @@ -175,5 +173,10 @@ namespace osu.Game.Rulesets.Mods } public bool Equals(IMod other) => GetType() == other?.GetType(); + + /// + /// Reset all custom settings for this mod back to their defaults. + /// + public virtual void ResetSettingsToDefaults() => CopyFrom((Mod)Activator.CreateInstance(GetType())); } } diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index 945dd444be..d1d23def67 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -15,7 +16,10 @@ namespace osu.Game.Rulesets.Mods public abstract class ModAutoplay : ModAutoplay, IApplicableToDrawableRuleset where T : HitObject { - public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) => drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap)); + public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); + } } public abstract class ModAutoplay : Mod, IApplicableFailOverride @@ -35,6 +39,11 @@ namespace osu.Game.Rulesets.Mods public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0; + [Obsolete("Use the mod-supporting override")] // can be removed 20210731 public virtual Score CreateReplayScore(IBeatmap beatmap) => new Score { Replay = new Replay() }; + +#pragma warning disable 618 + public virtual Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => CreateReplayScore(beatmap); +#pragma warning restore 618 } } diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index bee9e56edd..eb0473016a 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods { public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap)); + drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); // AlwaysPresent required for hitsounds drawableRuleset.Playfield.AlwaysPresent = true; diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index a531e885db..b70eee4e1d 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mods protected const int LAST_SETTING_ORDER = 2; [SettingSource("HP Drain", "Override a beatmap's set HP.", FIRST_SETTING_ORDER)] - public BindableNumber DrainRate { get; } = new BindableFloat + public BindableNumber DrainRate { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 0, @@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Mods }; [SettingSource("Accuracy", "Override a beatmap's set OD.", LAST_SETTING_ORDER)] - public BindableNumber OverallDifficulty { get; } = new BindableFloat + public BindableNumber OverallDifficulty { get; } = new BindableFloatWithLimitExtension { Precision = 0.1f, MinValue = 0, @@ -53,6 +53,24 @@ namespace osu.Game.Rulesets.Mods Value = 5, }; + [SettingSource("Extended Limits", "Adjust difficulty beyond sane limits.")] + public BindableBool ExtendedLimits { get; } = new BindableBool(); + + protected ModDifficultyAdjust() + { + ExtendedLimits.BindValueChanged(extend => ApplyLimits(extend.NewValue)); + } + + /// + /// Changes the difficulty adjustment limits. Occurs when the value of is changed. + /// + /// Whether limits should extend beyond sane ranges. + protected virtual void ApplyLimits(bool extended) + { + DrainRate.MaxValue = extended ? 11 : 10; + OverallDifficulty.MaxValue = extended ? 11 : 10; + } + public override string SettingDescription { get @@ -141,5 +159,73 @@ namespace osu.Game.Rulesets.Mods ApplySetting(DrainRate, dr => difficulty.DrainRate = dr); ApplySetting(OverallDifficulty, od => difficulty.OverallDifficulty = od); } + + public override void ResetSettingsToDefaults() + { + base.ResetSettingsToDefaults(); + + if (difficulty != null) + { + // base implementation potentially overwrite modified defaults that came from a beatmap selection. + TransferSettings(difficulty); + } + } + + /// + /// A that extends its min/max values to support any assigned value. + /// + protected class BindableDoubleWithLimitExtension : BindableDouble + { + public override double Value + { + get => base.Value; + set + { + if (value < MinValue) + MinValue = value; + if (value > MaxValue) + MaxValue = value; + base.Value = value; + } + } + } + + /// + /// A that extends its min/max values to support any assigned value. + /// + protected class BindableFloatWithLimitExtension : BindableFloat + { + public override float Value + { + get => base.Value; + set + { + if (value < MinValue) + MinValue = value; + if (value > MaxValue) + MaxValue = value; + base.Value = value; + } + } + } + + /// + /// A that extends its min/max values to support any assigned value. + /// + protected class BindableIntWithLimitExtension : BindableInt + { + public override int Value + { + get => base.Value; + set + { + if (value < MinValue) + MinValue = value; + if (value > MaxValue) + MaxValue = value; + base.Value = value; + } + } + } } } diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index 2150b0fb68..b016a6d43b 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics.Audio; namespace osu.Game.Rulesets.Mods { - public abstract class ModRateAdjust : Mod, IApplicableToAudio + public abstract class ModRateAdjust : Mod, IApplicableToRate { public abstract BindableNumber SpeedChange { get; } @@ -22,6 +22,8 @@ namespace osu.Game.Rulesets.Mods sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); } + public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value; + public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x"; } } diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index b6916c838e..330945d3d3 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -14,7 +14,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mods { - public abstract class ModTimeRamp : Mod, IUpdatableByPlayfield, IApplicableToBeatmap, IApplicableToAudio + public abstract class ModTimeRamp : Mod, IUpdatableByPlayfield, IApplicableToBeatmap, IApplicableToRate { /// /// The point in the beatmap at which the final ramping rate should be reached. @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mods protected ModTimeRamp() { // for preview purpose at song select. eventually we'll want to be able to update every frame. - FinalRate.BindValueChanged(val => applyRateAdjustment(1), true); + FinalRate.BindValueChanged(val => applyRateAdjustment(double.PositiveInfinity), true); AdjustPitch.BindValueChanged(applyPitchAdjustment); } @@ -75,17 +75,24 @@ namespace osu.Game.Rulesets.Mods finalRateTime = firstObjectStart + FINAL_RATE_PROGRESS * (lastObjectEnd - firstObjectStart); } + public double ApplyToRate(double time, double rate = 1) + { + double amount = (time - beginRampTime) / Math.Max(1, finalRateTime - beginRampTime); + double ramp = InitialRate.Value + (FinalRate.Value - InitialRate.Value) * Math.Clamp(amount, 0, 1); + + // round the end result to match the bindable SpeedChange's precision, in case this is called externally. + return rate * Math.Round(ramp, 2); + } + public virtual void Update(Playfield playfield) { - applyRateAdjustment((track.CurrentTime - beginRampTime) / Math.Max(1, finalRateTime - beginRampTime)); + applyRateAdjustment(track.CurrentTime); } /// - /// Adjust the rate along the specified ramp + /// Adjust the rate along the specified ramp. /// - /// The amount of adjustment to apply (from 0..1). - private void applyRateAdjustment(double amount) => - SpeedChange.Value = InitialRate.Value + (FinalRate.Value - InitialRate.Value) * Math.Clamp(amount, 0, 1); + private void applyRateAdjustment(double time) => SpeedChange.Value = ApplyToRate(time); private void applyPitchAdjustment(ValueChangedEvent adjustPitchSetting) { diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 6940e43e5b..ca27e6b21a 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.UI protected IRulesetConfigManager Config { get; private set; } [Cached(typeof(IReadOnlyList))] - protected override IReadOnlyList Mods { get; } + public sealed override IReadOnlyList Mods { get; } private FrameStabilityContainer frameStabilityContainer; @@ -434,7 +434,7 @@ namespace osu.Game.Rulesets.UI /// /// The mods which are to be applied. /// - protected abstract IReadOnlyList Mods { get; } + public abstract IReadOnlyList Mods { get; } /// ~ /// The associated ruleset. diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index 81ec73a6c5..deec948d14 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -102,9 +102,9 @@ namespace osu.Game.Rulesets.UI this.fallback = fallback; } - public SampleChannel Get(string name) => primary.Get(name) ?? fallback.Get(name); + public Sample Get(string name) => primary.Get(name) ?? fallback.Get(name); - public Task GetAsync(string name) => primary.GetAsync(name) ?? fallback.GetAsync(name); + public Task GetAsync(string name) => primary.GetAsync(name) ?? fallback.GetAsync(name); public Stream GetStream(string name) => primary.GetStream(name) ?? fallback.GetStream(name); diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index cf1d123c06..a6beb19876 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -71,8 +71,9 @@ namespace osu.Game.Scoring } } - protected override IEnumerable GetStableImportPaths(Storage stableStorage) - => stableStorage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)); + protected override IEnumerable GetStableImportPaths(Storage storage) + => storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) + .Select(path => storage.GetFullPath(path)); public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store); diff --git a/osu.Game/Screens/BackgroundScreen.cs b/osu.Game/Screens/BackgroundScreen.cs index c81362eebe..48c5523883 100644 --- a/osu.Game/Screens/BackgroundScreen.cs +++ b/osu.Game/Screens/BackgroundScreen.cs @@ -68,15 +68,19 @@ namespace osu.Game.Screens public override bool OnExiting(IScreen next) { - this.FadeOut(transition_length, Easing.OutExpo); - this.MoveToX(x_movement_amount, transition_length, Easing.OutExpo); + if (IsLoaded) + { + this.FadeOut(transition_length, Easing.OutExpo); + this.MoveToX(x_movement_amount, transition_length, Easing.OutExpo); + } return base.OnExiting(next); } public override void OnResuming(IScreen last) { - this.MoveToX(0, transition_length, Easing.OutExpo); + if (IsLoaded) + this.MoveToX(0, transition_length, Easing.OutExpo); base.OnResuming(last); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index c09b935f28..79f457c050 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -211,9 +211,6 @@ namespace osu.Game.Screens.Edit.Compose.Components if (blueprint != null) { - // doing this post-creations as adding the default hit sample should be the case regardless of the ruleset. - blueprint.HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_NORMAL)); - placementBlueprintContainer.Child = currentPlacement = blueprint; // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame diff --git a/osu.Game/Screens/Menu/Button.cs b/osu.Game/Screens/Menu/Button.cs index be6ed9700c..d956394ebb 100644 --- a/osu.Game/Screens/Menu/Button.cs +++ b/osu.Game/Screens/Menu/Button.cs @@ -45,8 +45,8 @@ namespace osu.Game.Screens.Menu public ButtonSystemState VisibleState = ButtonSystemState.TopLevel; private readonly Action clickAction; - private SampleChannel sampleClick; - private SampleChannel sampleHover; + private Sample sampleClick; + private Sample sampleHover; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index f400b2114b..81b1cb0bf1 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -81,7 +81,7 @@ namespace osu.Game.Screens.Menu private readonly List