diff --git a/osu.Android.props b/osu.Android.props index 69f897128c..650ebde54d 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,7 @@ - + diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index fe46876050..d75f4c70d7 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -24,7 +24,8 @@ namespace osu.Game.Rulesets.Osu.Mods public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModNoFail), typeof(ModAutoplay) }; - public bool AllowFail => false; + public bool PerformFail() => false; + public bool RestartOnFail => false; private OsuInputManager inputManager; diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs index 520961d3ce..16ef5b968d 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs @@ -3,6 +3,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; +using osu.Framework.Timing; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning; @@ -12,11 +13,30 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning { public class TestSceneTaikoScroller : TaikoSkinnableTestScene { + private readonly ManualClock clock = new ManualClock(); + + private bool reversed; + public TestSceneTaikoScroller() { - AddStep("Load scroller", () => SetContents(() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()))); + AddStep("Load scroller", () => SetContents(() => + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) + { + Clock = new FramedClock(clock), + Height = 0.4f, + })); + AddToggleStep("Toggle passing", passing => this.ChildrenOfType().ForEach(s => s.LastResult.Value = new JudgementResult(null, new Judgement()) { Type = passing ? HitResult.Perfect : HitResult.Miss })); + + AddToggleStep("toggle playback direction", reversed => this.reversed = reversed); + } + + protected override void Update() + { + base.Update(); + + clock.CurrentTime += (reversed ? -1 : 1) * Clock.ElapsedFrameTime; } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs index 1ecdb839fb..03813e0a99 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs @@ -17,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning { public class LegacyTaikoScroller : CompositeDrawable { + public Bindable LastResult = new Bindable(); + public LegacyTaikoScroller() { RelativeSizeAxes = Axes.Both; @@ -50,37 +52,38 @@ namespace osu.Game.Rulesets.Taiko.Skinning }, true); } - public Bindable LastResult = new Bindable(); - protected override void Update() { base.Update(); - while (true) + // store X before checking wide enough so if we perform layout there is no positional discrepancy. + float currentX = (InternalChildren?.FirstOrDefault()?.X ?? 0) - (float)Clock.ElapsedFrameTime * 0.1f; + + // ensure we have enough sprites + if (!InternalChildren.Any() + || InternalChildren.First().ScreenSpaceDrawQuad.Width * InternalChildren.Count < ScreenSpaceDrawQuad.Width * 2) + AddInternal(new ScrollerSprite { Passing = passing }); + + var first = InternalChildren.First(); + var last = InternalChildren.Last(); + + foreach (var sprite in InternalChildren) { - float? additiveX = null; + // add the x coordinates and perform re-layout on all sprites as spacing may change with gameplay scale. + sprite.X = currentX; + currentX += sprite.DrawWidth; + } - foreach (var sprite in InternalChildren) - { - // add the x coordinates and perform re-layout on all sprites as spacing may change with gameplay scale. - sprite.X = additiveX ??= sprite.X - (float)Time.Elapsed * 0.1f; + if (first.ScreenSpaceDrawQuad.TopLeft.X >= ScreenSpaceDrawQuad.TopLeft.X) + { + foreach (var internalChild in InternalChildren) + internalChild.X -= first.DrawWidth; + } - additiveX += sprite.DrawWidth - 1; - - if (sprite.X + sprite.DrawWidth < 0) - sprite.Expire(); - } - - var last = InternalChildren.LastOrDefault(); - - // only break from this loop once we have saturated horizontal space completely. - if (last != null && last.ScreenSpaceDrawQuad.TopRight.X >= ScreenSpaceDrawQuad.TopRight.X) - break; - - AddInternal(new ScrollerSprite - { - Passing = passing - }); + if (last.ScreenSpaceDrawQuad.TopRight.X <= ScreenSpaceDrawQuad.TopRight.X) + { + foreach (var internalChild in InternalChildren) + internalChild.X += first.DrawWidth; } } diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index 9b37af1111..e6aacf34dc 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.UI { new BarLineGenerator(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar.Major ? new DrawableBarLineMajor(bar) : new DrawableBarLine(bar))); - AddInternal(scroller = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) + FrameStableComponents.Add(scroller = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) { RelativeSizeAxes = Axes.X, Depth = float.MaxValue diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs index 980f5ea340..1041456020 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs @@ -13,18 +13,16 @@ namespace osu.Game.Rulesets.Taiko.UI private const float default_relative_height = TaikoPlayfield.DEFAULT_HEIGHT / 768; private const float default_aspect = 16f / 9f; - public TaikoPlayfieldAdjustmentContainer() - { - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - } - protected override void Update() { base.Update(); float aspectAdjust = Math.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect; Size = new Vector2(1, default_relative_height * aspectAdjust); + + // Position the taiko playfield exactly one playfield from the top of the screen. + RelativePositionAxes = Axes.Y; + Y = Size.Y; } } } diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index ef2b20de64..743c924bbd 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -211,7 +211,61 @@ namespace osu.Game.Tests.NonVisual var osu = loadOsu(host); Assert.DoesNotThrow(() => osu.Migrate(customPath)); - Assert.Throws(() => osu.Migrate(customPath)); + Assert.Throws(() => osu.Migrate(customPath)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationToNestedTargetFails() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigrationToNestedTargetFails))) + { + try + { + var osu = loadOsu(host); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + + string subFolder = Path.Combine(customPath, "sub"); + + if (Directory.Exists(subFolder)) + Directory.Delete(subFolder, true); + + Directory.CreateDirectory(subFolder); + + Assert.Throws(() => osu.Migrate(subFolder)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationToSeeminglyNestedTarget() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigrationToSeeminglyNestedTarget))) + { + try + { + var osu = loadOsu(host); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + + string seeminglySubFolder = customPath + "sub"; + + if (Directory.Exists(seeminglySubFolder)) + Directory.Delete(seeminglySubFolder, true); + + Directory.CreateDirectory(seeminglySubFolder); + + osu.Migrate(seeminglySubFolder); } finally { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs index e82722e7a2..1908988739 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs @@ -33,7 +33,8 @@ namespace osu.Game.Tests.Visual.Gameplay { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; public new HUDOverlay HUDOverlay => base.HUDOverlay; - public new bool AllowFail => base.AllowFail; + + public bool AllowFail => base.CheckModsAllowFailure(); protected override bool PauseOnFocusLost => false; diff --git a/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs b/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs new file mode 100644 index 0000000000..2883e54385 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Threading; +using osu.Framework.Screens; +using osu.Game.Overlays.Settings.Sections.Maintenance; + +namespace osu.Game.Tests.Visual.Settings +{ + public class TestSceneMigrationScreens : ScreenTestScene + { + public TestSceneMigrationScreens() + { + AddStep("Push screen", () => Stack.Push(new TestMigrationSelectScreen())); + } + + private class TestMigrationSelectScreen : MigrationSelectScreen + { + protected override void BeginMigration(DirectoryInfo target) => this.Push(new TestMigrationRunScreen()); + + private class TestMigrationRunScreen : MigrationRunScreen + { + protected override void PerformMigration() + { + Thread.Sleep(3000); + } + + public TestMigrationRunScreen() + : base(null) + { + } + } + } + } +} diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index 46bb7b83e3..52f761e50a 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -19,7 +19,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Editors { - public class SeedingEditorScreen : TournamentEditorScreen + public class SeedingEditorScreen : TournamentEditorScreen { private readonly TournamentTeam team; @@ -30,14 +30,14 @@ namespace osu.Game.Tournament.Screens.Editors this.team = team; } - public class SeeingResultRow : CompositeDrawable, IModelBacked + public class SeedingResultRow : CompositeDrawable, IModelBacked { public SeedingResult Model { get; } [Resolved] private LadderInfo ladderInfo { get; set; } - public SeeingResultRow(TournamentTeam team, SeedingResult round) + public SeedingResultRow(TournamentTeam team, SeedingResult round) { Model = round; @@ -281,6 +281,6 @@ namespace osu.Game.Tournament.Screens.Editors } } - protected override SeeingResultRow CreateDrawable(SeedingResult model) => new SeeingResultRow(team, model); + protected override SeedingResultRow CreateDrawable(SeedingResult model) => new SeedingResultRow(team, model); } } diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index b4c6d589d7..2c4fed8d86 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -42,7 +42,7 @@ namespace osu.Game.Tournament.Screens.MapPool { InternalChildren = new Drawable[] { - new TourneyVideo("gameplay") + new TourneyVideo("mappool") { Loop = true, RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Extensions/WebRequestExtensions.cs b/osu.Game/Extensions/WebRequestExtensions.cs new file mode 100644 index 0000000000..b940c7498b --- /dev/null +++ b/osu.Game/Extensions/WebRequestExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.IO.Network; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Extensions +{ + public static class WebRequestExtensions + { + /// + /// Add a pagination cursor to the web request in the format required by osu-web. + /// + public static void AddCursor(this WebRequest webRequest, Cursor cursor) + { + cursor?.Properties.ForEach(x => + { + webRequest.AddParameter("cursor[" + x.Key + "]", x.Value.ToString()); + }); + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs index ee428c0047..ae34281bfb 100644 --- a/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs @@ -28,11 +28,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 private GameHost host { get; set; } [Cached] - private readonly Bindable currentDirectory = new Bindable(); + public readonly Bindable CurrentDirectory = new Bindable(); public DirectorySelector(string initialPath = null) { - currentDirectory.Value = new DirectoryInfo(initialPath ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + CurrentDirectory.Value = new DirectoryInfo(initialPath ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); } [BackgroundDependencyLoader] @@ -40,19 +40,25 @@ namespace osu.Game.Graphics.UserInterfaceV2 { Padding = new MarginPadding(10); - InternalChildren = new Drawable[] + InternalChild = new GridContainer { - new FillFlowContainer + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new Dimension(GridSizeMode.Absolute, 50), + new Dimension(), + }, + Content = new[] + { + new Drawable[] { new CurrentDirectoryDisplay { - RelativeSizeAxes = Axes.X, - Height = 50, + RelativeSizeAxes = Axes.Both, }, + }, + new Drawable[] + { new OsuScrollContainer { RelativeSizeAxes = Axes.Both, @@ -65,10 +71,10 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } } - }, + } }; - currentDirectory.BindValueChanged(updateDisplay, true); + CurrentDirectory.BindValueChanged(updateDisplay, true); } private void updateDisplay(ValueChangedEvent directory) @@ -86,9 +92,9 @@ namespace osu.Game.Graphics.UserInterfaceV2 } else { - directoryFlow.Add(new ParentDirectoryPiece(currentDirectory.Value.Parent)); + directoryFlow.Add(new ParentDirectoryPiece(CurrentDirectory.Value.Parent)); - foreach (var dir in currentDirectory.Value.GetDirectories().OrderBy(d => d.Name)) + foreach (var dir in CurrentDirectory.Value.GetDirectories().OrderBy(d => d.Name)) { if ((dir.Attributes & FileAttributes.Hidden) == 0) directoryFlow.Add(new DirectoryPiece(dir)); @@ -97,8 +103,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 } catch (Exception) { - currentDirectory.Value = directory.OldValue; - + CurrentDirectory.Value = directory.OldValue; this.FlashColour(Color4.Red, 300); } } diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index 71b01ce479..499bcb4063 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -48,11 +48,21 @@ namespace osu.Game.IO var source = new DirectoryInfo(GetFullPath(".")); var destination = new DirectoryInfo(newLocation); + // using Uri is the easiest way to check equality and contains (https://stackoverflow.com/a/7710620) + var sourceUri = new Uri(source.FullName + Path.DirectorySeparatorChar); + var destinationUri = new Uri(destination.FullName + Path.DirectorySeparatorChar); + + if (sourceUri == destinationUri) + throw new ArgumentException("Destination provided is already the current location", nameof(newLocation)); + + if (sourceUri.IsBaseOf(destinationUri)) + throw new ArgumentException("Destination provided is inside the source", nameof(newLocation)); + // ensure the new location has no files present, else hard abort if (destination.Exists) { if (destination.GetFiles().Length > 0 || destination.GetDirectories().Length > 0) - throw new InvalidOperationException("Migration destination already has files present"); + throw new ArgumentException("Destination provided already has files or directories present", nameof(newLocation)); deleteRecursive(destination); } @@ -74,7 +84,7 @@ namespace osu.Game.IO if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name)) continue; - fi.Delete(); + attemptOperation(() => fi.Delete()); } foreach (DirectoryInfo dir in target.GetDirectories()) @@ -82,8 +92,11 @@ namespace osu.Game.IO if (topLevelExcludes && IGNORE_DIRECTORIES.Contains(dir.Name)) continue; - dir.Delete(true); + attemptOperation(() => dir.Delete(true)); } + + if (target.GetFiles().Length == 0 && target.GetDirectories().Length == 0) + attemptOperation(target.Delete); } private static void copyRecursive(DirectoryInfo source, DirectoryInfo destination, bool topLevelExcludes = true) @@ -96,7 +109,7 @@ namespace osu.Game.IO if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name)) continue; - attemptCopy(fi, Path.Combine(destination.FullName, fi.Name)); + attemptOperation(() => fi.CopyTo(Path.Combine(destination.FullName, fi.Name), true)); } foreach (DirectoryInfo dir in source.GetDirectories()) @@ -108,24 +121,27 @@ namespace osu.Game.IO } } - private static void attemptCopy(System.IO.FileInfo fileInfo, string destination) + /// + /// Attempt an IO operation multiple times and only throw if none of the attempts succeed. + /// + /// The action to perform. + /// The number of attempts (250ms wait between each). + private static void attemptOperation(Action action, int attempts = 10) { - int tries = 5; - while (true) { try { - fileInfo.CopyTo(destination, true); + action(); return; } catch (Exception) { - if (tries-- == 0) + if (attempts-- == 0) throw; } - Thread.Sleep(50); + Thread.Sleep(250); } } } diff --git a/osu.Game/Online/API/Requests/Cursor.cs b/osu.Game/Online/API/Requests/Cursor.cs new file mode 100644 index 0000000000..f21445ca32 --- /dev/null +++ b/osu.Game/Online/API/Requests/Cursor.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.API.Requests +{ + /// + /// A collection of parameters which should be passed to the search endpoint to fetch the next page. + /// + public class Cursor + { + [UsedImplicitly] + [JsonExtensionData] + public IDictionary Properties; + } +} diff --git a/osu.Game/Online/API/Requests/ResponseWithCursor.cs b/osu.Game/Online/API/Requests/ResponseWithCursor.cs index e38e73dd01..d52e999722 100644 --- a/osu.Game/Online/API/Requests/ResponseWithCursor.cs +++ b/osu.Game/Online/API/Requests/ResponseWithCursor.cs @@ -7,10 +7,7 @@ namespace osu.Game.Online.API.Requests { public abstract class ResponseWithCursor { - /// - /// A collection of parameters which should be passed to the search endpoint to fetch the next page. - /// [JsonProperty("cursor")] - public dynamic CursorJson; + public Cursor Cursor; } } diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs index 047496b473..0c3272c7de 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.IO.Network; +using osu.Game.Extensions; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; using osu.Game.Rulesets; @@ -10,29 +11,31 @@ namespace osu.Game.Online.API.Requests { public class SearchBeatmapSetsRequest : APIRequest { - public SearchCategory SearchCategory { get; set; } + public SearchCategory SearchCategory { get; } - public SortCriteria SortCriteria { get; set; } + public SortCriteria SortCriteria { get; } - public SortDirection SortDirection { get; set; } + public SortDirection SortDirection { get; } - public SearchGenre Genre { get; set; } + public SearchGenre Genre { get; } - public SearchLanguage Language { get; set; } + public SearchLanguage Language { get; } private readonly string query; private readonly RulesetInfo ruleset; + private readonly Cursor cursor; private string directionString => SortDirection == SortDirection.Descending ? @"desc" : @"asc"; - public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset) + public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset, Cursor cursor = null, SearchCategory searchCategory = SearchCategory.Any, SortCriteria sortCriteria = SortCriteria.Ranked, SortDirection sortDirection = SortDirection.Descending) { this.query = string.IsNullOrEmpty(query) ? string.Empty : System.Uri.EscapeDataString(query); this.ruleset = ruleset; + this.cursor = cursor; - SearchCategory = SearchCategory.Any; - SortCriteria = SortCriteria.Ranked; - SortDirection = SortDirection.Descending; + SearchCategory = searchCategory; + SortCriteria = sortCriteria; + SortDirection = sortDirection; Genre = SearchGenre.Any; Language = SearchLanguage.Any; } @@ -55,6 +58,8 @@ namespace osu.Game.Online.API.Requests req.AddParameter("sort", $"{SortCriteria.ToString().ToLowerInvariant()}_{directionString}"); + req.AddCursor(cursor); + return req; } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 4dd60c7113..41c99d5d03 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -22,25 +22,46 @@ namespace osu.Game.Overlays.BeatmapListing { public class BeatmapListingFilterControl : CompositeDrawable { + /// + /// Fired when a search finishes. Contains only new items in the case of pagination. + /// public Action> SearchFinished; + + /// + /// Fired when search criteria change. + /// public Action SearchStarted; + /// + /// True when pagination has reached the end of available results. + /// + private bool noMoreResults; + + /// + /// The current page fetched of results (zero index). + /// + public int CurrentPage { get; private set; } + + private readonly BeatmapListingSearchControl searchControl; + private readonly BeatmapListingSortTabControl sortControl; + private readonly Box sortControlBackground; + + private ScheduledDelegate queryChangedDebounce; + + private SearchBeatmapSetsRequest getSetsRequest; + private SearchBeatmapSetsResponse lastResponse; + [Resolved] private IAPIProvider api { get; set; } [Resolved] private RulesetStore rulesets { get; set; } - private readonly BeatmapListingSearchControl searchControl; - private readonly BeatmapListingSortTabControl sortControl; - private readonly Box sortControlBackground; - - private SearchBeatmapSetsRequest getSetsRequest; - public BeatmapListingFilterControl() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + InternalChild = new FillFlowContainer { RelativeSizeAxes = Axes.X, @@ -114,51 +135,84 @@ namespace osu.Game.Overlays.BeatmapListing sortDirection.BindValueChanged(_ => queueUpdateSearch()); } - private ScheduledDelegate queryChangedDebounce; + public void TakeFocus() => searchControl.TakeFocus(); + + /// + /// Fetch the next page of results. May result in a no-op if a fetch is already in progress, or if there are no results left. + /// + public void FetchNextPage() + { + // there may be no results left. + if (noMoreResults) + return; + + // there may already be an active request. + if (getSetsRequest != null) + return; + + if (lastResponse != null) + CurrentPage++; + + performRequest(); + } private void queueUpdateSearch(bool queryTextChanged = false) { SearchStarted?.Invoke(); - getSetsRequest?.Cancel(); + resetSearch(); - queryChangedDebounce?.Cancel(); - queryChangedDebounce = Scheduler.AddDelayed(updateSearch, queryTextChanged ? 500 : 100); + queryChangedDebounce = Scheduler.AddDelayed(() => + { + resetSearch(); + FetchNextPage(); + }, queryTextChanged ? 500 : 100); } - private void updateSearch() + private void performRequest() { - getSetsRequest = new SearchBeatmapSetsRequest(searchControl.Query.Value, searchControl.Ruleset.Value) - { - SearchCategory = searchControl.Category.Value, - SortCriteria = sortControl.Current.Value, - SortDirection = sortControl.SortDirection.Value, - Genre = searchControl.Genre.Value, - Language = searchControl.Language.Value - }; + getSetsRequest = new SearchBeatmapSetsRequest( + searchControl.Query.Value, + searchControl.Ruleset.Value, + lastResponse?.Cursor, + searchControl.Category.Value, + sortControl.Current.Value, + sortControl.SortDirection.Value); - getSetsRequest.Success += response => Schedule(() => onSearchFinished(response)); + getSetsRequest.Success += response => + { + var sets = response.BeatmapSets.Select(responseJson => responseJson.ToBeatmapSet(rulesets)).ToList(); + + if (sets.Count == 0) + noMoreResults = true; + + lastResponse = response; + getSetsRequest = null; + + SearchFinished?.Invoke(sets); + }; api.Queue(getSetsRequest); } - private void onSearchFinished(SearchBeatmapSetsResponse response) + private void resetSearch() { - var beatmaps = response.BeatmapSets.Select(r => r.ToBeatmapSet(rulesets)).ToList(); + noMoreResults = false; + CurrentPage = 0; - searchControl.BeatmapSet = response.Total == 0 ? null : beatmaps.First(); + lastResponse = null; - SearchFinished?.Invoke(beatmaps); + getSetsRequest?.Cancel(); + getSetsRequest = null; + + queryChangedDebounce?.Cancel(); } protected override void Dispose(bool isDisposing) { - getSetsRequest?.Cancel(); - queryChangedDebounce?.Cancel(); + resetSearch(); base.Dispose(isDisposing); } - - public void TakeFocus() => searchControl.TakeFocus(); } } diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index f680f7c67b..225a8a0578 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -4,7 +4,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -30,6 +32,10 @@ namespace osu.Game.Overlays private Drawable currentContent; private LoadingLayer loadingLayer; private Container panelTarget; + private FillFlowContainer foundContent; + private NotFoundDrawable notFoundContent; + + private OverlayScrollContainer resultScrollContainer; public BeatmapListingOverlay() : base(OverlayColourScheme.Blue) @@ -48,7 +54,7 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, Colour = ColourProvider.Background6 }, - new OverlayScrollContainer + resultScrollContainer = new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, @@ -80,9 +86,14 @@ namespace osu.Game.Overlays { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 20 } - }, - loadingLayer = new LoadingLayer(panelTarget) + Padding = new MarginPadding { Horizontal = 20 }, + Children = new Drawable[] + { + foundContent = new FillFlowContainer(), + notFoundContent = new NotFoundDrawable(), + loadingLayer = new LoadingLayer(panelTarget) + } + } } }, } @@ -110,34 +121,53 @@ namespace osu.Game.Overlays loadingLayer.Show(); } + private Task panelLoadDelegate; + private void onSearchFinished(List beatmaps) { - if (!beatmaps.Any()) + var newPanels = beatmaps.Select(b => new GridBeatmapPanel(b) { - LoadComponentAsync(new NotFoundDrawable(), addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); - return; - } + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }); - var newPanels = new FillFlowContainer + if (filterControl.CurrentPage == 0) { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), - Alpha = 0, - Margin = new MarginPadding { Vertical = 15 }, - ChildrenEnumerable = beatmaps.Select(b => new GridBeatmapPanel(b) + //No matches case + if (!newPanels.Any()) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }) - }; + LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + return; + } - LoadComponentAsync(newPanels, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + // spawn new children with the contained so we only clear old content at the last moment. + var content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Alpha = 0, + Margin = new MarginPadding { Vertical = 15 }, + ChildrenEnumerable = newPanels + }; + + panelLoadDelegate = LoadComponentAsync(foundContent = content, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + } + else + { + panelLoadDelegate = LoadComponentsAsync(newPanels, loaded => + { + lastFetchDisplayedTime = Time.Current; + foundContent.AddRange(loaded); + loaded.ForEach(p => p.FadeIn(200, Easing.OutQuint)); + }); + } } private void addContentToPlaceholder(Drawable content) { loadingLayer.Hide(); + lastFetchDisplayedTime = Time.Current; var lastContent = currentContent; @@ -149,11 +179,14 @@ namespace osu.Game.Overlays // If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird. // At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0. // To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so. - lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y); + lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => panelTarget.Remove(lastContent)); } - panelTarget.Add(currentContent = content); - currentContent.FadeIn(200, Easing.OutQuint); + if (!content.IsAlive) + panelTarget.Add(content); + content.FadeIn(200, Easing.OutQuint); + + currentContent = content; } protected override void Dispose(bool isDisposing) @@ -203,5 +236,23 @@ namespace osu.Game.Overlays }); } } + + private const double time_between_fetches = 500; + + private double lastFetchDisplayedTime; + + protected override void Update() + { + base.Update(); + + const int pagination_scroll_distance = 500; + + bool shouldShowMore = panelLoadDelegate?.IsCompleted != false + && Time.Current - lastFetchDisplayedTime > time_between_fetches + && (resultScrollContainer.ScrollableExtent > 0 && resultScrollContainer.IsScrolledToEnd(pagination_scroll_distance)); + + if (shouldShowMore) + filterControl.FetchNextPage(); + } } } diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 188c9c05ef..95a1868392 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -4,7 +4,9 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Platform; +using osu.Framework.Screens; using osu.Game.Configuration; +using osu.Game.Overlays.Settings.Sections.Maintenance; namespace osu.Game.Overlays.Settings.Sections.General { @@ -12,8 +14,8 @@ namespace osu.Game.Overlays.Settings.Sections.General { protected override string Header => "Updates"; - [BackgroundDependencyLoader] - private void load(Storage storage, OsuConfigManager config) + [BackgroundDependencyLoader(true)] + private void load(Storage storage, OsuConfigManager config, OsuGame game) { Add(new SettingsEnumDropdown { @@ -28,6 +30,12 @@ namespace osu.Game.Overlays.Settings.Sections.General Text = "Open osu! folder", Action = storage.OpenInNativeExplorer, }); + + Add(new SettingsButton + { + Text = "Change folder location...", + Action = () => game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())) + }); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs new file mode 100644 index 0000000000..b0b61554eb --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -0,0 +1,115 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens; +using osuTK; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class MigrationRunScreen : OsuScreen + { + private readonly DirectoryInfo destination; + + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + + public override bool AllowBackButton => false; + + public override bool AllowExternalScreenChange => false; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool HideOverlaysOnEnter => true; + + private Task migrationTask; + + public MigrationRunScreen(DirectoryInfo destination) + { + this.destination = destination; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(10), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Migration in progress", + Font = OsuFont.Default.With(size: 40) + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "This could take a few minutes depending on the speed of your disk(s).", + Font = OsuFont.Default.With(size: 30) + }, + new LoadingSpinner(true) + { + State = { Value = Visibility.Visible } + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Please avoid interacting with the game!", + Font = OsuFont.Default.With(size: 30) + }, + } + }, + }; + + Beatmap.Value = Beatmap.Default; + + migrationTask = Task.Run(PerformMigration) + .ContinueWith(t => + { + if (t.IsFaulted) + Logger.Log($"Error during migration: {t.Exception?.Message}", level: LogLevel.Error); + + Schedule(this.Exit); + }); + } + + protected virtual void PerformMigration() => game?.Migrate(destination.FullName); + + public override void OnEntering(IScreen last) + { + base.OnEntering(last); + + this.FadeOut().Delay(250).Then().FadeIn(250); + } + + public override bool OnExiting(IScreen next) + { + // block until migration is finished + if (migrationTask?.IsCompleted == false) + return true; + + return base.OnExiting(next); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs new file mode 100644 index 0000000000..79d842a617 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs @@ -0,0 +1,128 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Screens; +using osuTK; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class MigrationSelectScreen : OsuScreen + { + private DirectorySelector directorySelector; + + public override bool AllowExternalScreenChange => false; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool HideOverlaysOnEnter => true; + + [BackgroundDependencyLoader(true)] + private void load(OsuGame game, Storage storage, OsuColour colours) + { + game?.Toolbar.Hide(); + + // begin selection in the parent directory of the current storage location + var initialPath = new DirectoryInfo(storage.GetFullPath(string.Empty)).Parent?.FullName; + + InternalChild = new Container + { + Masking = true, + CornerRadius = 10, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.5f, 0.8f), + Children = new Drawable[] + { + new Box + { + Colour = colours.GreySeafoamDark, + RelativeSizeAxes = Axes.Both, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Relative, 0.8f), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Please select a new location", + Font = OsuFont.Default.With(size: 40) + }, + }, + new Drawable[] + { + directorySelector = new DirectorySelector(initialPath) + { + RelativeSizeAxes = Axes.Both, + } + }, + new Drawable[] + { + new TriangleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + Text = "Begin folder migration", + Action = start + }, + } + } + } + } + }; + } + + public override void OnSuspending(IScreen next) + { + base.OnSuspending(next); + + this.FadeOut(250); + } + + private void start() + { + var target = directorySelector.CurrentDirectory.Value; + + try + { + if (target.GetDirectories().Length > 0 || target.GetFiles().Length > 0) + target = target.CreateSubdirectory("osu-lazer"); + } + catch (Exception e) + { + Logger.Log($"Error during migration: {e.Message}", level: LogLevel.Error); + return; + } + + ValidForResume = false; + BeginMigration(target); + } + + protected virtual void BeginMigration(DirectoryInfo target) => this.Push(new MigrationRunScreen(target)); + } +} diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs index 676d2c941a..eb639431ae 100644 --- a/osu.Game/Overlays/VolumeOverlay.cs +++ b/osu.Game/Overlays/VolumeOverlay.cs @@ -46,6 +46,13 @@ namespace osu.Game.Overlays Width = 300, Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.75f), Color4.Black.Opacity(0)) }, + muteButton = new MuteButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding(10), + Current = { BindTarget = IsMuted } + }, new FillFlowContainer { Direction = FillDirection.Vertical, @@ -56,19 +63,11 @@ namespace osu.Game.Overlays Margin = new MarginPadding { Left = offset }, Children = new Drawable[] { - volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker) - { - Margin = new MarginPadding { Top = 100 + MuteButton.HEIGHT } // to counter the mute button and re-center the volume meters - }, + volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker), volumeMeterMaster = new VolumeMeter("MASTER", 150, colours.PinkDarker), volumeMeterMusic = new VolumeMeter("MUSIC", 125, colours.BlueDarker), - muteButton = new MuteButton - { - Margin = new MarginPadding { Top = 100 }, - Current = { BindTarget = IsMuted } - } } - }, + } }); volumeMeterMaster.Bindable.BindTo(audio.Volume); diff --git a/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs b/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs index 120bfc9a23..8c99d739cb 100644 --- a/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs +++ b/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs @@ -11,10 +11,11 @@ namespace osu.Game.Rulesets.Mods /// /// Whether we should allow failing at the current point in time. /// - bool AllowFail { get; } + /// Whether the fail should be allowed to proceed. Return false to block. + bool PerformFail(); /// - /// Whether we want to restart on fail. Only used if is true. + /// Whether we want to restart on fail. Only used if returns true. /// bool RestartOnFail { get; } } diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index e51b8b6457..945dd444be 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -27,7 +27,8 @@ namespace osu.Game.Rulesets.Mods public override string Description => "Watch a perfect automated play through the song."; public override double ScoreMultiplier => 1; - public bool AllowFail => false; + public bool PerformFail() => false; + public bool RestartOnFail => false; public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModNoFail) }; diff --git a/osu.Game/Rulesets/Mods/ModBlockFail.cs b/osu.Game/Rulesets/Mods/ModBlockFail.cs index 7d7ecfa416..1fde5abad4 100644 --- a/osu.Game/Rulesets/Mods/ModBlockFail.cs +++ b/osu.Game/Rulesets/Mods/ModBlockFail.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods /// /// We never fail, 'yo. /// - public bool AllowFail => false; + public bool PerformFail() => false; public bool RestartOnFail => false; diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index c1c4124b98..7cf9656810 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -48,17 +48,14 @@ namespace osu.Game.Rulesets.Mods retries = Retries.Value; } - public bool AllowFail + public bool PerformFail() { - get - { - if (retries == 0) return true; + if (retries == 0) return true; - health.Value = health.MaxValue; - retries--; + health.Value = health.MaxValue; + retries--; - return false; - } + return false; } public bool RestartOnFail => false; diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index 8799431f1d..df10262845 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -20,7 +20,8 @@ namespace osu.Game.Rulesets.Mods public override bool Ranked => true; public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModRelax), typeof(ModAutoplay) }; - public bool AllowFail => true; + public bool PerformFail() => true; + public bool RestartOnFail => true; public void ApplyToHealthProcessor(HealthProcessor healthProcessor) diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 591e969ad8..2f85d6ad1e 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -78,10 +78,10 @@ namespace osu.Game.Screens.Play // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. - platformOffsetClock = new FramedOffsetClock(adjustableClock) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; + platformOffsetClock = new HardwareCorrectionOffsetClock(adjustableClock) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; // the final usable gameplay clock with user-set offsets applied. - userOffsetClock = new FramedOffsetClock(platformOffsetClock); + userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock); // the clock to be exposed via DI to children. GameplayClock = new GameplayClock(userOffsetClock); @@ -248,5 +248,16 @@ namespace osu.Game.Screens.Play speedAdjustmentsApplied = false; } } + + private class HardwareCorrectionOffsetClock : FramedOffsetClock + { + // we always want to apply the same real-time offset, so it should be adjusted by the playback rate to achieve this. + public override double CurrentTime => SourceTime + Offset * Rate; + + public HardwareCorrectionOffsetClock(IClock source, bool processSource = true) + : base(source, processSource) + { + } + } } } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index a2735c8c55..1ec3a69b24 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -105,7 +105,7 @@ namespace osu.Game.Screens.Play /// Whether failing should be allowed. /// By default, this checks whether all selected mods allow failing. /// - protected virtual bool AllowFail => Mods.Value.OfType().All(m => m.AllowFail); + protected virtual bool CheckModsAllowFailure() => Mods.Value.OfType().All(m => m.PerformFail()); private readonly bool allowPause; private readonly bool showResults; @@ -485,7 +485,7 @@ namespace osu.Game.Screens.Play private bool onFail() { - if (!AllowFail) + if (!CheckModsAllowFailure()) return false; HasFailed = true; diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 0d2ddb7b01..f0c76163f1 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -12,7 +12,7 @@ namespace osu.Game.Screens.Play private readonly Score score; // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) - protected override bool AllowFail => false; + protected override bool CheckModsAllowFailure() => false; public ReplayPlayer(Score score, bool allowPause = true, bool showResults = true) : base(allowPause, showResults) diff --git a/osu.Game/Tests/Visual/ModPerfectTestScene.cs b/osu.Game/Tests/Visual/ModPerfectTestScene.cs index 5948283428..95a62bbf65 100644 --- a/osu.Game/Tests/Visual/ModPerfectTestScene.cs +++ b/osu.Game/Tests/Visual/ModPerfectTestScene.cs @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual { } - protected override bool AllowFail => true; + protected override bool CheckModsAllowFailure() => true; public bool CheckFailed(bool failed) { diff --git a/osu.Game/Tests/Visual/ModTestScene.cs b/osu.Game/Tests/Visual/ModTestScene.cs index 1fa638b3d8..d36a7d8bad 100644 --- a/osu.Game/Tests/Visual/ModTestScene.cs +++ b/osu.Game/Tests/Visual/ModTestScene.cs @@ -59,12 +59,14 @@ namespace osu.Game.Tests.Visual protected class ModTestPlayer : TestPlayer { - protected override bool AllowFail { get; } + private readonly bool allowFail; + + protected override bool CheckModsAllowFailure() => allowFail; public ModTestPlayer(bool allowFail) : base(false, false) { - AllowFail = allowFail; + this.allowFail = allowFail; } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index c6dba8da13..ee6206e166 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -25,7 +25,7 @@ - + diff --git a/osu.iOS.props b/osu.iOS.props index f78fd2e4ff..cbf8600c62 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -71,7 +71,7 @@ - +