diff --git a/osu.Android.props b/osu.Android.props index aad8cf10d0..3b70df82c2 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,7 +52,7 @@ - + diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 405f0a8006..f95348aa3f 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -113,6 +113,9 @@ namespace osu.Desktop { tools.CreateShortcutForThisExe(); tools.CreateUninstallerRegistryEntry(); + }, onAppUpdate: (version, tools) => + { + tools.CreateUninstallerRegistryEntry(); }, onAppUninstall: (version, tools) => { tools.RemoveShortcutForThisExe(); diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index 8c229cf6c5..3bcfce3a56 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -25,8 +25,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing /// public readonly double StrainTime; - public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth, List objects, int position) - : base(hitObject, lastObject, clockRate, objects, position) + public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth, List objects, int index) + : base(hitObject, lastObject, clockRate, objects, index) { // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. float scalingFactor = normalized_hitobject_radius / halfCatcherWidth; diff --git a/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs index 5c9b3b9b54..1c4204145b 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs @@ -12,8 +12,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Preprocessing { public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject; - public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int position) - : base(hitObject, lastObject, clockRate, objects, position) + public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) + : base(hitObject, lastObject, clockRate, objects, index) { } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index f16e62d2a7..6906c8af86 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators /// public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliders) { - if (current.BaseObject is Spinner || current.Position <= 1 || current.Previous(0).BaseObject is Spinner) + if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) return 0; var osuCurrObj = (OsuDifficultyHitObject)current; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index b13b641c6b..619560f0b3 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators OsuDifficultyHitObject lastObj = osuCurrent; // This is iterating backwards in time from the current object. - for (int i = 0; i < Math.Min(current.Position, 10); i++) + for (int i = 0; i < Math.Min(current.Index, 10); i++) { var currentObj = (OsuDifficultyHitObject)current.Previous(i); var currentHitObject = (OsuHitObject)(currentObj.BaseObject); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 1da5dd52d9..345789b99d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators bool firstDeltaSwitch = false; - int historicalNoteCount = Math.Min(current.Position, 32); + int historicalNoteCount = Math.Min(current.Index, 32); int rhythmStart = 0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index 17d4246c72..85203dd157 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // derive strainTime for calculation var osuCurrObj = (OsuDifficultyHitObject)current; - var osuPrevObj = current.Position > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; + var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; double strainTime = osuCurrObj.StrainTime; double greatWindowFull = greatWindow * 2; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index f074783ccf..0fa69c180a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -92,7 +92,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty // The first jump is formed by the first two hitobjects of the map. // If the map has less than two OsuHitObjects, the enumerator will not return anything. for (int i = 1; i < beatmap.HitObjects.Count; i++) - objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate, objects, objects.Count)); + { + var lastLast = i > 1 ? beatmap.HitObjects[i - 2] : null; + objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], lastLast, clockRate, objects, objects.Count)); + } return objects; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 0400dd0db1..20921dd282 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -75,10 +75,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; - public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int position) - : base(hitObject, lastObject, clockRate, objects, position) + public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, List objects, int index) + : base(hitObject, lastObject, clockRate, objects, index) { - lastLastObject = (OsuHitObject)Previous(1)?.BaseObject; + this.lastLastObject = (OsuHitObject)lastLastObject; this.lastObject = (OsuHitObject)lastObject; // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs index 450eb63636..d9049c45ea 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -39,9 +39,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing /// The gameplay preceding . /// The rate of the gameplay clock. Modified by speed-changing mods. /// The list of s in the current beatmap. - /// /// The position of this in the list. - public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, List objects, int position) - : base(hitObject, lastObject, clockRate, objects, position) + /// /// The position of this in the list. + public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, List objects, int index) + : base(hitObject, lastObject, clockRate, objects, index) { var currentHit = hitObject as Hit; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index 9150b6980b..46b24af903 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -106,7 +106,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills if (!samePattern(start, mostRecentPatternsToCompare)) continue; - int notesSince = hitObject.Position - rhythmHistory[start].Position; + int notesSince = hitObject.Index - rhythmHistory[start].Index; penalty *= repetitionPenalty(notesSince); break; } diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs index 5cbede54f5..41bc075803 100644 --- a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs +++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs @@ -49,11 +49,16 @@ namespace osu.Game.Tests.Collections.IO Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + // Even with no beatmaps imported, collections are tracking the hashes and will continue to. + // In the future this whole mechanism will be replaced with having the collections in realm, + // but until that happens it makes rough sense that we want to track not-yet-imported beatmaps + // and have them associate with collections if/when they become available. + Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); - Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.Zero); + Assert.That(osu.CollectionManager.Collections[0].BeatmapHashes.Count, Is.EqualTo(1)); Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); - Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.Zero); + Assert.That(osu.CollectionManager.Collections[1].BeatmapHashes.Count, Is.EqualTo(12)); } finally { @@ -76,10 +81,10 @@ namespace osu.Game.Tests.Collections.IO Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); - Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(1)); + Assert.That(osu.CollectionManager.Collections[0].BeatmapHashes.Count, Is.EqualTo(1)); Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); - Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(12)); + Assert.That(osu.CollectionManager.Collections[1].BeatmapHashes.Count, Is.EqualTo(12)); } finally { @@ -142,8 +147,8 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); // Move first beatmap from second collection into the first. - osu.CollectionManager.Collections[0].Beatmaps.Add(osu.CollectionManager.Collections[1].Beatmaps[0]); - osu.CollectionManager.Collections[1].Beatmaps.RemoveAt(0); + osu.CollectionManager.Collections[0].BeatmapHashes.Add(osu.CollectionManager.Collections[1].BeatmapHashes[0]); + osu.CollectionManager.Collections[1].BeatmapHashes.RemoveAt(0); // Rename the second collecction. osu.CollectionManager.Collections[1].Name.Value = "Another"; @@ -164,10 +169,10 @@ namespace osu.Game.Tests.Collections.IO Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); - Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(2)); + Assert.That(osu.CollectionManager.Collections[0].BeatmapHashes.Count, Is.EqualTo(2)); Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Another")); - Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(11)); + Assert.That(osu.CollectionManager.Collections[1].BeatmapHashes.Count, Is.EqualTo(11)); } finally { diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 888002eb36..602c7c84b8 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -152,7 +152,7 @@ namespace osu.Game.Tests.Visual.Collections AddStep("add two collections with same name", () => manager.Collections.AddRange(new[] { new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "1" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, + new BeatmapCollection { Name = { Value = "1" }, BeatmapHashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } }, })); } @@ -162,7 +162,7 @@ namespace osu.Game.Tests.Visual.Collections AddStep("add two collections", () => manager.Collections.AddRange(new[] { new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "2" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, + new BeatmapCollection { Name = { Value = "2" }, BeatmapHashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } }, })); assertCollectionCount(2); @@ -198,7 +198,7 @@ namespace osu.Game.Tests.Visual.Collections { AddStep("add two collections", () => manager.Collections.AddRange(new[] { - new BeatmapCollection { Name = { Value = "1" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, + new BeatmapCollection { Name = { Value = "1" }, BeatmapHashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } }, })); assertCollectionCount(1); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs b/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs new file mode 100644 index 0000000000..fe14935479 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs @@ -0,0 +1,96 @@ +// 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.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Utility; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Settings +{ + public class TestSceneLatencyCertifierScreen : ScreenTestScene + { + private LatencyCertifierScreen latencyCertifier = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("Load screen", () => LoadScreen(latencyCertifier = new LatencyCertifierScreen())); + AddUntilStep("wait for load", () => latencyCertifier.IsLoaded); + } + + [Test] + public void TestSimple() + { + AddStep("set visual mode to simple", () => latencyCertifier.VisualMode.Value = LatencyVisualMode.Simple); + } + + [Test] + public void TestCircleGameplay() + { + AddStep("set visual mode to circles", () => latencyCertifier.VisualMode.Value = LatencyVisualMode.CircleGameplay); + } + + [Test] + public void TestScrollingGameplay() + { + AddStep("set visual mode to scrolling", () => latencyCertifier.VisualMode.Value = LatencyVisualMode.ScrollingGameplay); + } + + [Test] + public void TestCycleVisualModes() + { + AddRepeatStep("cycle mode", () => InputManager.Key(Key.Space), 6); + } + + [Test] + public void TestCertification() + { + checkDifficulty(1); + clickUntilResults(true); + continueFromResults(); + checkDifficulty(2); + + clickUntilResults(false); + continueFromResults(); + checkDifficulty(1); + + clickUntilResults(true); + AddAssert("check at results", () => !latencyCertifier.ChildrenOfType().Any()); + checkDifficulty(1); + } + + private void continueFromResults() + { + AddAssert("check at results", () => !latencyCertifier.ChildrenOfType().Any()); + AddStep("hit enter to continue", () => InputManager.Key(Key.Enter)); + } + + private void checkDifficulty(int difficulty) + { + AddAssert($"difficulty is {difficulty}", () => latencyCertifier.DifficultyLevel == difficulty); + } + + private void clickUntilResults(bool clickCorrect) + { + AddUntilStep("click correct button until results", () => + { + var latencyArea = latencyCertifier + .ChildrenOfType() + .SingleOrDefault(a => clickCorrect ? a.TargetFrameRate == null : a.TargetFrameRate != null); + + // reached results + if (latencyArea == null) + return true; + + latencyArea.ChildrenOfType().Single().TriggerClick(); + return false; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs index b7ec128596..b42ce3ff87 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs @@ -151,10 +151,10 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); - AddStep("add beatmap to collection", () => collectionManager.Collections[0].Beatmaps.Add(Beatmap.Value.BeatmapInfo)); + AddStep("add beatmap to collection", () => collectionManager.Collections[0].BeatmapHashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash)); AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); - AddStep("remove beatmap from collection", () => collectionManager.Collections[0].Beatmaps.Clear()); + AddStep("remove beatmap from collection", () => collectionManager.Collections[0].BeatmapHashes.Clear()); AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); } @@ -169,11 +169,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); addClickAddOrRemoveButtonStep(1); - AddAssert("collection contains beatmap", () => collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo)); + AddAssert("collection contains beatmap", () => collectionManager.Collections[0].BeatmapHashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); addClickAddOrRemoveButtonStep(1); - AddAssert("collection does not contain beatmap", () => !collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo)); + AddAssert("collection does not contain beatmap", () => !collectionManager.Collections[0].BeatmapHashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index abc9020dc6..5925dd7064 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -90,6 +90,7 @@ namespace osu.Game.Beatmaps public double StarRating { get; set; } + [Indexed] public string MD5Hash { get; set; } = string.Empty; [JsonIgnore] diff --git a/osu.Game/Collections/BeatmapCollection.cs b/osu.Game/Collections/BeatmapCollection.cs index 7e4b15ecf9..cf95c74b46 100644 --- a/osu.Game/Collections/BeatmapCollection.cs +++ b/osu.Game/Collections/BeatmapCollection.cs @@ -23,9 +23,9 @@ namespace osu.Game.Collections public readonly Bindable Name = new Bindable(); /// - /// The beatmaps contained by the collection. + /// The es of beatmaps contained by the collection. /// - public readonly BindableList Beatmaps = new BindableList(); + public readonly BindableList BeatmapHashes = new BindableList(); /// /// The date when this collection was last modified. @@ -34,7 +34,7 @@ namespace osu.Game.Collections public BeatmapCollection() { - Beatmaps.CollectionChanged += (_, __) => onChange(); + BeatmapHashes.CollectionChanged += (_, __) => onChange(); Name.ValueChanged += _ => onChange(); } diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs index c46ba8e06e..100074d186 100644 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ b/osu.Game/Collections/CollectionFilterDropdown.cs @@ -38,7 +38,7 @@ namespace osu.Game.Collections } private readonly IBindableList collections = new BindableList(); - private readonly IBindableList beatmaps = new BindableList(); + private readonly IBindableList beatmaps = new BindableList(); private readonly BindableList filters = new BindableList(); [Resolved(CanBeNull = true)] @@ -95,10 +95,10 @@ namespace osu.Game.Collections beatmaps.CollectionChanged -= filterBeatmapsChanged; if (filter.OldValue?.Collection != null) - beatmaps.UnbindFrom(filter.OldValue.Collection.Beatmaps); + beatmaps.UnbindFrom(filter.OldValue.Collection.BeatmapHashes); if (filter.NewValue?.Collection != null) - beatmaps.BindTo(filter.NewValue.Collection.Beatmaps); + beatmaps.BindTo(filter.NewValue.Collection.BeatmapHashes); beatmaps.CollectionChanged += filterBeatmapsChanged; @@ -196,7 +196,7 @@ namespace osu.Game.Collections private IBindable beatmap { get; set; } [CanBeNull] - private readonly BindableList collectionBeatmaps; + private readonly BindableList collectionBeatmaps; [NotNull] private readonly Bindable collectionName; @@ -208,7 +208,7 @@ namespace osu.Game.Collections public CollectionDropdownMenuItem(MenuItem item) : base(item) { - collectionBeatmaps = Item.Collection?.Beatmaps.GetBoundCopy(); + collectionBeatmaps = Item.Collection?.BeatmapHashes.GetBoundCopy(); collectionName = Item.CollectionName.GetBoundCopy(); } @@ -258,7 +258,7 @@ namespace osu.Game.Collections { Debug.Assert(collectionBeatmaps != null); - beatmapInCollection = collectionBeatmaps.Contains(beatmap.Value.BeatmapInfo); + beatmapInCollection = collectionBeatmaps.Contains(beatmap.Value.BeatmapInfo.MD5Hash); addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; @@ -285,8 +285,8 @@ namespace osu.Game.Collections { Debug.Assert(collectionBeatmaps != null); - if (!collectionBeatmaps.Remove(beatmap.Value.BeatmapInfo)) - collectionBeatmaps.Add(beatmap.Value.BeatmapInfo); + if (!collectionBeatmaps.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) + collectionBeatmaps.Add(beatmap.Value.BeatmapInfo.MD5Hash); } protected override Drawable CreateContent() => content = (Content)base.CreateContent(); diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index 700b0f5dcb..104ec4beb2 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -13,7 +13,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO; using osu.Game.IO.Legacy; @@ -40,9 +39,6 @@ namespace osu.Game.Collections public readonly BindableList Collections = new BindableList(); - [Resolved] - private BeatmapManager beatmaps { get; set; } - private readonly Storage storage; public CollectionManager(Storage storage) @@ -173,10 +169,10 @@ namespace osu.Game.Collections if (existing == null) Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); - foreach (var newBeatmap in newCol.Beatmaps) + foreach (string newBeatmap in newCol.BeatmapHashes) { - if (!existing.Beatmaps.Contains(newBeatmap)) - existing.Beatmaps.Add(newBeatmap); + if (!existing.BeatmapHashes.Contains(newBeatmap)) + existing.BeatmapHashes.Add(newBeatmap); } } @@ -226,9 +222,7 @@ namespace osu.Game.Collections string checksum = sr.ReadString(); - var beatmap = beatmaps.QueryBeatmap(b => b.MD5Hash == checksum); - if (beatmap != null) - collection.Beatmaps.Add(beatmap); + collection.BeatmapHashes.Add(checksum); } if (notification != null) @@ -299,11 +293,12 @@ namespace osu.Game.Collections { sw.Write(c.Name.Value); - var beatmapsCopy = c.Beatmaps.ToArray(); + string[] beatmapsCopy = c.BeatmapHashes.ToArray(); + sw.Write(beatmapsCopy.Length); - foreach (var b in beatmapsCopy) - sw.Write(b.MD5Hash); + foreach (string b in beatmapsCopy) + sw.Write(b); } } diff --git a/osu.Game/Collections/DeleteCollectionDialog.cs b/osu.Game/Collections/DeleteCollectionDialog.cs index e5a2f6fb81..e59adb14a6 100644 --- a/osu.Game/Collections/DeleteCollectionDialog.cs +++ b/osu.Game/Collections/DeleteCollectionDialog.cs @@ -13,7 +13,7 @@ namespace osu.Game.Collections public DeleteCollectionDialog(BeatmapCollection collection, Action deleteAction) { HeaderText = "Confirm deletion of"; - BodyText = $"{collection.Name.Value} ({"beatmap".ToQuantity(collection.Beatmaps.Count)})"; + BodyText = $"{collection.Name.Value} ({"beatmap".ToQuantity(collection.BeatmapHashes.Count)})"; Icon = FontAwesome.Regular.TrashAlt; diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 5a20b7e7bd..5064041737 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -225,7 +225,7 @@ namespace osu.Game.Collections { background.FlashColour(Color4.White, 150); - if (collection.Beatmaps.Count == 0) + if (collection.BeatmapHashes.Count == 0) deleteCollection(); else dialogOverlay?.Push(new DeleteCollectionDialog(collection, deleteCollection)); diff --git a/osu.Game/Graphics/OsuFont.cs b/osu.Game/Graphics/OsuFont.cs index edb484021c..9d4e97b88e 100644 --- a/osu.Game/Graphics/OsuFont.cs +++ b/osu.Game/Graphics/OsuFont.cs @@ -35,7 +35,10 @@ namespace osu.Game.Graphics /// Whether all characters should be spaced the same distance apart. /// The . public static FontUsage GetFont(Typeface typeface = Typeface.Torus, float size = DEFAULT_FONT_SIZE, FontWeight weight = FontWeight.Medium, bool italics = false, bool fixedWidth = false) - => new FontUsage(GetFamilyString(typeface), size, GetWeightString(typeface, weight), getItalics(italics), fixedWidth); + { + string familyString = GetFamilyString(typeface); + return new FontUsage(familyString, size, GetWeightString(familyString, weight), getItalics(italics), fixedWidth); + } private static bool getItalics(in bool italicsRequested) { @@ -54,16 +57,16 @@ namespace osu.Game.Graphics switch (typeface) { case Typeface.Venera: - return "Venera"; + return @"Venera"; case Typeface.Torus: - return "Torus"; + return @"Torus"; case Typeface.TorusAlternate: - return "Torus-Alternate"; + return @"Torus-Alternate"; case Typeface.Inter: - return "Inter"; + return @"Inter"; } return null; @@ -72,25 +75,17 @@ namespace osu.Game.Graphics /// /// Retrieves the string representation of a . /// - /// The . - /// The . - /// The string representation of in the specified . - public static string GetWeightString(Typeface typeface, FontWeight weight) + /// The font family. + /// The font weight. + /// The string representation of in the specified . + public static string GetWeightString(string family, FontWeight weight) { - if (typeface == Typeface.Torus && weight == FontWeight.Medium) + if ((family == GetFamilyString(Typeface.Torus) || family == GetFamilyString(Typeface.TorusAlternate)) && weight == FontWeight.Medium) // torus doesn't have a medium; fallback to regular. weight = FontWeight.Regular; - return GetWeightString(GetFamilyString(typeface), weight); + return weight.ToString(); } - - /// - /// Retrieves the string representation of a . - /// - /// The family string. - /// The . - /// The string representation of in the specified . - public static string GetWeightString(string family, FontWeight weight) => weight.ToString(); } public static class OsuFontExtensions diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs index 1c9aa64df5..38355d9041 100644 --- a/osu.Game/Localisation/GraphicsSettingsStrings.cs +++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs @@ -34,11 +34,6 @@ namespace osu.Game.Localisation /// public static LocalisableString ShowFPS => new TranslatableString(getKey(@"show_fps"), @"Show FPS"); - /// - /// "Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. "2x refresh rate" is recommended." - /// - public static LocalisableString UnlimitedFramesNote => new TranslatableString(getKey(@"unlimited_frames_note"), @"Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. ""2x refresh rate"" is recommended."); - /// /// "Layout" /// diff --git a/osu.Game/Online/ProductionEndpointConfiguration.cs b/osu.Game/Online/ProductionEndpointConfiguration.cs index e44dad1db5..c6ddc03564 100644 --- a/osu.Game/Online/ProductionEndpointConfiguration.cs +++ b/osu.Game/Online/ProductionEndpointConfiguration.cs @@ -10,8 +10,8 @@ namespace osu.Game.Online WebsiteRootUrl = APIEndpointUrl = @"https://osu.ppy.sh"; APIClientSecret = @"FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk"; APIClientID = "5"; - SpectatorEndpointUrl = "https://spectator2.ppy.sh/spectator"; - MultiplayerEndpointUrl = "https://spectator2.ppy.sh/multiplayer"; + SpectatorEndpointUrl = "https://spectator.ppy.sh/spectator"; + MultiplayerEndpointUrl = "https://spectator.ppy.sh/multiplayer"; } } } diff --git a/osu.Game/Overlays/Music/Playlist.cs b/osu.Game/Overlays/Music/Playlist.cs index 24d867141c..ff8f3197f9 100644 --- a/osu.Game/Overlays/Music/Playlist.cs +++ b/osu.Game/Overlays/Music/Playlist.cs @@ -30,7 +30,15 @@ namespace osu.Game.Overlays.Music var items = (SearchContainer>>)ListContainer; foreach (var item in items.OfType()) - item.InSelectedCollection = criteria.Collection?.Beatmaps.Any(b => item.Model.ID == b.BeatmapSet?.ID) ?? true; + { + if (criteria.Collection == null) + item.InSelectedCollection = true; + else + { + item.InSelectedCollection = item.Model.Value.Beatmaps.Select(b => b.MD5Hash) + .Any(criteria.Collection.BeatmapHashes.Contains); + } + } items.SearchTerm = criteria.SearchText; } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs index 8833420523..2b845e9d6b 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs @@ -9,6 +9,7 @@ using osu.Framework.Screens; using osu.Game.Localisation; using osu.Game.Screens; using osu.Game.Screens.Import; +using osu.Game.Screens.Utility; namespace osu.Game.Overlays.Settings.Sections.DebugSettings { @@ -30,13 +31,18 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings { LabelText = DebugSettingsStrings.BypassFrontToBackPass, Current = config.GetBindable(DebugSetting.BypassFrontToBackPass) + }, + new SettingsButton + { + Text = DebugSettingsStrings.ImportFiles, + Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())) + }, + new SettingsButton + { + Text = @"Run latency certifier", + Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen())) } }; - Add(new SettingsButton - { - Text = DebugSettingsStrings.ImportFiles, - Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())) - }); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 8c3e45cd62..5ba8e724cf 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -15,8 +15,6 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { protected override LocalisableString Header => GraphicsSettingsStrings.RendererHeader; - private SettingsEnumDropdown frameLimiterDropdown; - [BackgroundDependencyLoader] private void load(FrameworkConfigManager config, OsuConfigManager osuConfig) { @@ -24,7 +22,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics Children = new Drawable[] { // TODO: this needs to be a custom dropdown at some point - frameLimiterDropdown = new SettingsEnumDropdown + new SettingsEnumDropdown { LabelText = GraphicsSettingsStrings.FrameLimiter, Current = config.GetBindable(FrameworkSetting.FrameSync) @@ -41,24 +39,5 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics }, }; } - - protected override void LoadComplete() - { - base.LoadComplete(); - - frameLimiterDropdown.Current.BindValueChanged(limit => - { - switch (limit.NewValue) - { - case FrameSync.Unlimited: - frameLimiterDropdown.SetNoticeText(GraphicsSettingsStrings.UnlimitedFramesNote, true); - break; - - default: - frameLimiterDropdown.ClearNoticeText(); - break; - } - }, true); - } } } diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index a886a0d992..5a2a40e241 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -15,9 +15,9 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing private readonly IReadOnlyList difficultyHitObjects; /// - /// The position of this in the list. + /// The index of this in the list of all s. /// - public int Position; + public int Index; /// /// The this wraps. @@ -51,11 +51,11 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing /// The last which occurs before in the beatmap. /// The rate at which the gameplay clock is run at. /// The list of s in the current beatmap. - /// The position of this in the list. - public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int position) + /// The index of this in list. + public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) { difficultyHitObjects = objects; - Position = position; + Index = index; BaseObject = hitObject; LastObject = lastObject; DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate; @@ -63,8 +63,8 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing EndTime = hitObject.GetEndTime() / clockRate; } - public DifficultyHitObject Previous(int backwardsIndex) => difficultyHitObjects.ElementAtOrDefault(Position - (backwardsIndex + 1)); + public DifficultyHitObject Previous(int backwardsIndex) => difficultyHitObjects.ElementAtOrDefault(Index - (backwardsIndex + 1)); - public DifficultyHitObject Next(int forwardsIndex) => difficultyHitObjects.ElementAtOrDefault(Position + (forwardsIndex + 1)); + public DifficultyHitObject Next(int forwardsIndex) => difficultyHitObjects.ElementAtOrDefault(Index + (forwardsIndex + 1)); } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 6be10db8b3..0b327046b3 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills public sealed override void Process(DifficultyHitObject current) { // The first object doesn't generate a strain, so we begin with an incremented section end - if (current.Position == 0) + if (current.Index == 0) currentSectionEnd = Math.Ceiling(current.StartTime / SectionLength) * SectionLength; while (current.StartTime > currentSectionEnd) diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index fd6a869938..c3f6b3ad83 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -72,7 +72,7 @@ namespace osu.Game.Screens.Select.Carousel } if (match) - match &= criteria.Collection?.Beatmaps.Contains(BeatmapInfo) ?? true; + match &= criteria.Collection?.BeatmapHashes.Contains(BeatmapInfo.MD5Hash) ?? true; if (match && criteria.RulesetCriteria != null) match &= criteria.RulesetCriteria.Matches(BeatmapInfo); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 98b885eb43..065a29b53c 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -256,12 +256,12 @@ namespace osu.Game.Screens.Select.Carousel return new ToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => { if (s) - collection.Beatmaps.Add(beatmapInfo); + collection.BeatmapHashes.Add(beatmapInfo.MD5Hash); else - collection.Beatmaps.Remove(beatmapInfo); + collection.BeatmapHashes.Remove(beatmapInfo.MD5Hash); }) { - State = { Value = collection.Beatmaps.Contains(beatmapInfo) } + State = { Value = collection.BeatmapHashes.Contains(beatmapInfo.MD5Hash) } }; } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 2d70b1aecb..80f1231454 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -245,7 +245,7 @@ namespace osu.Game.Screens.Select.Carousel TernaryState state; - int countExisting = beatmapSet.Beatmaps.Count(b => collection.Beatmaps.Contains(b)); + int countExisting = beatmapSet.Beatmaps.Count(b => collection.BeatmapHashes.Contains(b.MD5Hash)); if (countExisting == beatmapSet.Beatmaps.Count) state = TernaryState.True; @@ -261,14 +261,14 @@ namespace osu.Game.Screens.Select.Carousel switch (s) { case TernaryState.True: - if (collection.Beatmaps.Contains(b)) + if (collection.BeatmapHashes.Contains(b.MD5Hash)) continue; - collection.Beatmaps.Add(b); + collection.BeatmapHashes.Add(b.MD5Hash); break; case TernaryState.False: - collection.Beatmaps.Remove(b); + collection.BeatmapHashes.Remove(b.MD5Hash); break; } } diff --git a/osu.Game/Screens/Utility/ButtonWithKeyBind.cs b/osu.Game/Screens/Utility/ButtonWithKeyBind.cs new file mode 100644 index 0000000000..ef87e0bca7 --- /dev/null +++ b/osu.Game/Screens/Utility/ButtonWithKeyBind.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable +using osu.Framework.Allocation; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osuTK.Input; + +namespace osu.Game.Screens.Utility +{ + public class ButtonWithKeyBind : SettingsButton + { + private readonly Key key; + + public ButtonWithKeyBind(Key key) + { + this.key = key; + } + + public new LocalisableString Text + { + get => base.Text; + set => base.Text = $"{value} (Press {key.ToString().Replace("Number", string.Empty)})"; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!e.Repeat && e.Key == key) + { + TriggerClick(); + return true; + } + + return base.OnKeyDown(e); + } + + [Resolved] + private OverlayColourProvider overlayColourProvider { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Height = 100; + SpriteText.Colour = overlayColourProvider.Background6; + SpriteText.Font = OsuFont.TorusAlternate.With(size: 34); + } + } +} diff --git a/osu.Game/Screens/Utility/CircleGameplay.cs b/osu.Game/Screens/Utility/CircleGameplay.cs new file mode 100644 index 0000000000..dd59721a65 --- /dev/null +++ b/osu.Game/Screens/Utility/CircleGameplay.cs @@ -0,0 +1,240 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Utility.SampleComponents; +using osuTK; + +namespace osu.Game.Screens.Utility +{ + public class CircleGameplay : LatencySampleComponent + { + private int nextLocation; + + private readonly List hitEvents = new List(); + + private double? lastGeneratedBeatTime; + + private Container circles = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + circles = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }; + + SampleBPM.BindValueChanged(_ => + { + circles.Clear(); + lastGeneratedBeatTime = null; + }); + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + double beatLength = 60000 / SampleBPM.Value; + + int nextBeat = (int)(Clock.CurrentTime / beatLength) + 1; + + // We want to generate a few hit objects ahead of the current time (to allow them to animate). + double generateUpTo = (nextBeat + 2) * beatLength; + + while (lastGeneratedBeatTime == null || lastGeneratedBeatTime < generateUpTo) + { + double time = ++nextBeat * beatLength; + + if (time <= lastGeneratedBeatTime) + continue; + + newBeat(time); + lastGeneratedBeatTime = time; + } + } + + private void newBeat(double time) + { + nextLocation++; + + Vector2 location; + + float adjust = SampleVisualSpacing.Value * 0.25f; + + float spacingLow = 0.5f - adjust; + float spacingHigh = 0.5f + adjust; + + switch (nextLocation % 4) + { + default: + location = new Vector2(spacingLow, spacingLow); + break; + + case 1: + location = new Vector2(spacingHigh, spacingHigh); + break; + + case 2: + location = new Vector2(spacingHigh, spacingLow); + break; + + case 3: + location = new Vector2(spacingLow, spacingHigh); + break; + } + + circles.Add(new SampleHitCircle(time) + { + RelativePositionAxes = Axes.Both, + Position = location, + Hit = hit, + }); + } + + private void hit(HitEvent h) + { + hitEvents.Add(h); + } + + public class SampleHitCircle : LatencySampleComponent + { + public HitEvent? HitEvent; + + public Action? Hit { get; set; } + + public readonly double HitTime; + + private CircularContainer approach = null!; + private Circle circle = null!; + + private const float size = 100; + private const float duration = 200; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + => circle.ReceivePositionalInputAt(screenSpacePos); + + public SampleHitCircle(double hitTime) + { + HitTime = hitTime; + + Origin = Anchor.Centre; + AutoSizeAxes = Axes.Both; + AlwaysPresent = true; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new Drawable[] + { + circle = new Circle + { + Colour = OverlayColourProvider.Content1, + Size = new Vector2(size), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + approach = new CircularContainer + { + BorderColour = colours.Blue, + Size = new Vector2(size), + Masking = true, + BorderThickness = 4, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Alpha = 0, + AlwaysPresent = true, + RelativeSizeAxes = Axes.Both, + }, + } + }, + }; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (HitEvent != null) + return false; + + if (Math.Abs(Clock.CurrentTime - HitTime) > duration) + return false; + + attemptHit(); + return true; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!IsActive.Value) + return false; + + if (Math.Abs(Clock.CurrentTime - HitTime) > duration) + return false; + + if (IsHovered) + attemptHit(); + return base.OnKeyDown(e); + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + if (HitEvent == null) + { + double preempt = (float)IBeatmapDifficultyInfo.DifficultyRange(SampleApproachRate.Value, 1800, 1200, 450); + + approach.Scale = new Vector2(1 + 4 * (float)MathHelper.Clamp((HitTime - Clock.CurrentTime) / preempt, 0, 100)); + Alpha = (float)MathHelper.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); + + if (Clock.CurrentTime > HitTime + duration) + Expire(); + } + } + + private void attemptHit() => Schedule(() => + { + if (HitEvent != null) + return; + + // in case it was hit outside of display range, show immediately + // so the user isn't confused. + this.FadeIn(); + + approach.Expire(); + + circle + .FadeOut(duration) + .ScaleTo(1.5f, duration); + + HitEvent = new HitEvent(Clock.CurrentTime - HitTime, HitResult.Good, new HitObject + { + HitWindows = new HitWindows(), + }, null, null); + + Hit?.Invoke(HitEvent.Value); + + this.Delay(duration).Expire(); + }); + } + } +} diff --git a/osu.Game/Screens/Utility/LatencyArea.cs b/osu.Game/Screens/Utility/LatencyArea.cs new file mode 100644 index 0000000000..21688f0b0c --- /dev/null +++ b/osu.Game/Screens/Utility/LatencyArea.cs @@ -0,0 +1,150 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +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.Overlays; +using osu.Game.Screens.Utility.SampleComponents; +using osuTK.Input; + +namespace osu.Game.Screens.Utility +{ + [Cached] + public class LatencyArea : CompositeDrawable + { + [Resolved] + private OverlayColourProvider overlayColourProvider { get; set; } = null!; + + public Action? ReportUserBest { get; set; } + + private Drawable? background; + + private readonly Key key; + + private Container visualContent = null!; + + public readonly int? TargetFrameRate; + + public readonly BindableBool IsActiveArea = new BindableBool(); + + public readonly Bindable VisualMode = new Bindable(); + + public LatencyArea(Key key, int? targetFrameRate) + { + this.key = key; + TargetFrameRate = targetFrameRate; + + RelativeSizeAxes = Axes.Both; + Masking = true; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new[] + { + background = new Box + { + Colour = overlayColourProvider.Background6, + RelativeSizeAxes = Axes.Both, + }, + new ButtonWithKeyBind(key) + { + Text = "Feels better", + Y = 20, + Width = 0.8f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = () => ReportUserBest?.Invoke(), + }, + visualContent = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }; + + IsActiveArea.BindValueChanged(active => + { + background.FadeColour(active.NewValue ? overlayColourProvider.Background4 : overlayColourProvider.Background6, 200, Easing.OutQuint); + }, true); + + VisualMode.BindValueChanged(mode => + { + switch (mode.NewValue) + { + case LatencyVisualMode.Simple: + visualContent.Children = new Drawable[] + { + new LatencyMovableBox + { + RelativeSizeAxes = Axes.Both, + }, + new LatencyCursorContainer + { + RelativeSizeAxes = Axes.Both, + }, + }; + break; + + case LatencyVisualMode.CircleGameplay: + visualContent.Children = new Drawable[] + { + new CircleGameplay + { + RelativeSizeAxes = Axes.Both, + }, + new LatencyCursorContainer + { + RelativeSizeAxes = Axes.Both, + }, + }; + break; + + case LatencyVisualMode.ScrollingGameplay: + visualContent.Children = new Drawable[] + { + new ScrollingGameplay + { + RelativeSizeAxes = Axes.Both, + }, + new LatencyCursorContainer + { + RelativeSizeAxes = Axes.Both, + }, + }; + break; + + default: + throw new ArgumentOutOfRangeException(); + } + }, true); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + IsActiveArea.Value = true; + return base.OnMouseMove(e); + } + + private double lastFrameTime; + + public override bool UpdateSubTree() + { + double elapsed = Clock.CurrentTime - lastFrameTime; + if (TargetFrameRate.HasValue && elapsed < 1000.0 / TargetFrameRate) + return false; + + lastFrameTime = Clock.CurrentTime; + + return base.UpdateSubTree(); + } + } +} diff --git a/osu.Game/Screens/Utility/LatencyCertifierScreen.cs b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs new file mode 100644 index 0000000000..8210e6825a --- /dev/null +++ b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs @@ -0,0 +1,532 @@ +// 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.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osu.Framework.Platform.Windows; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Utility +{ + [Cached] + public class LatencyCertifierScreen : OsuScreen + { + private FrameSync previousFrameSyncMode; + private double previousActiveHz; + + private readonly OsuTextFlowContainer statusText; + + public override bool HideOverlaysOnEnter => true; + + public override bool CursorVisible => mainArea.Count == 0; + + public override float BackgroundParallaxAmount => 0; + + private readonly LinkFlowContainer explanatoryText; + + private readonly Container mainArea; + + private readonly Container resultsArea; + + public readonly BindableDouble SampleBPM = new BindableDouble(120) { MinValue = 60, MaxValue = 300, Precision = 1 }; + public readonly BindableDouble SampleApproachRate = new BindableDouble(9) { MinValue = 5, MaxValue = 12, Precision = 0.1 }; + public readonly BindableFloat SampleVisualSpacing = new BindableFloat(0.5f) { MinValue = 0f, MaxValue = 1, Precision = 0.1f }; + + /// + /// The rate at which the game host should attempt to run. + /// + private const int target_host_update_frames = 4000; + + [Cached] + private readonly OverlayColourProvider overlayColourProvider = new OverlayColourProvider(OverlayColourScheme.Orange); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private FrameworkConfigManager config { get; set; } = null!; + + public readonly Bindable VisualMode = new Bindable(); + + private const int rounds_to_complete = 5; + + private const int rounds_to_complete_certified = 20; + + /// + /// Whether we are now in certification mode and decreasing difficulty. + /// + private bool isCertifying; + + private int totalRoundForNextResultsScreen => isCertifying ? rounds_to_complete_certified : rounds_to_complete; + + private int attemptsAtCurrentDifficulty; + private int correctAtCurrentDifficulty; + + public int DifficultyLevel { get; private set; } = 1; + + private double lastPoll; + private int pollingMax; + + private readonly FillFlowContainer settings; + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved] + private MusicController musicController { get; set; } = null!; + + public LatencyCertifierScreen() + { + InternalChildren = new Drawable[] + { + new Box + { + Colour = overlayColourProvider.Background6, + RelativeSizeAxes = Axes.Both, + }, + mainArea = new Container + { + RelativeSizeAxes = Axes.Both, + }, + // Make sure the edge between the two comparisons can't be used to ascertain latency. + new Box + { + Name = "separator", + Colour = ColourInfo.GradientHorizontal(overlayColourProvider.Background6, overlayColourProvider.Background6.Opacity(0)), + Width = 100, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopLeft, + }, + new Box + { + Name = "separator", + Colour = ColourInfo.GradientHorizontal(overlayColourProvider.Background6.Opacity(0), overlayColourProvider.Background6), + Width = 100, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight, + }, + settings = new FillFlowContainer + { + Name = "Settings", + AutoSizeAxes = Axes.Y, + Width = 800, + Padding = new MarginPadding(10), + Spacing = new Vector2(2), + Direction = FillDirection.Vertical, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Children = new Drawable[] + { + explanatoryText = new LinkFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20)) + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + TextAnchor = Anchor.TopCentre, + }, + new SettingsSlider + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.None, + Width = 400, + LabelText = "bpm", + Current = SampleBPM + }, + new SettingsSlider + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.None, + Width = 400, + LabelText = "visual spacing", + Current = SampleVisualSpacing + }, + new SettingsSlider + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.None, + Width = 400, + LabelText = "approach rate", + Current = SampleApproachRate + }, + }, + }, + resultsArea = new Container + { + RelativeSizeAxes = Axes.Both, + }, + statusText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 40)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + TextAnchor = Anchor.TopCentre, + Y = 150, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }; + + explanatoryText.AddParagraph(@"Welcome to the latency certifier!"); + explanatoryText.AddParagraph(@"Do whatever you need to try and perceive the difference in latency, then choose your best side. Read more about the methodology "); + explanatoryText.AddLink("here", "https://github.com/ppy/osu/wiki/Latency-and-unlimited-frame-rates#methodology"); + explanatoryText.AddParagraph(@"Use the arrow keys or Z/X/F/J to control the display."); + explanatoryText.AddParagraph(@"Tab key to change focus. Space to change display mode"); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (lastPoll > 0 && Clock.CurrentTime != lastPoll) + pollingMax = (int)Math.Max(pollingMax, 1000 / (Clock.CurrentTime - lastPoll)); + lastPoll = Clock.CurrentTime; + return base.OnMouseMove(e); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + previousFrameSyncMode = config.Get(FrameworkSetting.FrameSync); + previousActiveHz = host.UpdateThread.ActiveHz; + config.SetValue(FrameworkSetting.FrameSync, FrameSync.Unlimited); + host.UpdateThread.ActiveHz = target_host_update_frames; + host.AllowBenchmarkUnlimitedFrames = true; + + musicController.Stop(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + host.AllowBenchmarkUnlimitedFrames = false; + config.SetValue(FrameworkSetting.FrameSync, previousFrameSyncMode); + host.UpdateThread.ActiveHz = previousActiveHz; + return base.OnExiting(e); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + loadNextRound(); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Space: + int availableModes = Enum.GetValues(typeof(LatencyVisualMode)).Length; + VisualMode.Value = (LatencyVisualMode)(((int)VisualMode.Value + 1) % availableModes); + return true; + + case Key.Tab: + var firstArea = mainArea.FirstOrDefault(a => !a.IsActiveArea.Value); + if (firstArea != null) + firstArea.IsActiveArea.Value = true; + return true; + } + + return base.OnKeyDown(e); + } + + private void showResults() + { + mainArea.Clear(); + resultsArea.Clear(); + settings.Hide(); + + var displayMode = host.Window?.CurrentDisplayMode.Value; + + string exclusive = "unknown"; + + if (host.Window is WindowsWindow windowsWindow) + exclusive = windowsWindow.FullscreenCapability.ToString(); + + statusText.Clear(); + + float successRate = (float)correctAtCurrentDifficulty / attemptsAtCurrentDifficulty; + bool isPass = successRate == 1; + + statusText.AddParagraph($"You scored {correctAtCurrentDifficulty} out of {attemptsAtCurrentDifficulty} ({successRate:0%})!", cp => cp.Colour = isPass ? colours.Green : colours.Red); + statusText.AddParagraph($"Level {DifficultyLevel} ({mapDifficultyToTargetFrameRate(DifficultyLevel):N0} Hz)", + cp => cp.Font = OsuFont.Default.With(size: 24)); + + statusText.AddParagraph(string.Empty); + statusText.AddParagraph(string.Empty); + statusText.AddIcon(isPass ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.TimesCircle, cp => cp.Colour = isPass ? colours.Green : colours.Red); + statusText.AddParagraph(string.Empty); + + if (!isPass && DifficultyLevel > 1) + { + statusText.AddParagraph("To complete certification, the difficulty level will now decrease until you can get 20 rounds correct in a row!", + cp => cp.Font = OsuFont.Default.With(size: 24, weight: FontWeight.SemiBold)); + statusText.AddParagraph(string.Empty); + } + + statusText.AddParagraph($"Polling: {pollingMax} Hz Monitor: {displayMode?.RefreshRate ?? 0:N0} Hz Exclusive: {exclusive}", + cp => cp.Font = OsuFont.Default.With(size: 15, weight: FontWeight.SemiBold)); + + statusText.AddParagraph($"Input: {host.InputThread.Clock.FramesPerSecond} Hz " + + $"Update: {host.UpdateThread.Clock.FramesPerSecond} Hz " + + $"Draw: {host.DrawThread.Clock.FramesPerSecond} Hz" + , cp => cp.Font = OsuFont.Default.With(size: 15, weight: FontWeight.SemiBold)); + + if (isCertifying && isPass) + { + showCertifiedScreen(); + return; + } + + string cannotIncreaseReason = string.Empty; + + if (mapDifficultyToTargetFrameRate(DifficultyLevel + 1) > target_host_update_frames) + cannotIncreaseReason = "You've reached the maximum level."; + else if (mapDifficultyToTargetFrameRate(DifficultyLevel + 1) > Clock.FramesPerSecond) + cannotIncreaseReason = "Game is not running fast enough to test this level"; + + FillFlowContainer buttonFlow; + + resultsArea.Add(buttonFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Spacing = new Vector2(20), + Padding = new MarginPadding(20), + }); + + if (isPass) + { + buttonFlow.Add(new ButtonWithKeyBind(Key.Enter) + { + Text = "Continue to next level", + BackgroundColour = colours.Green, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => changeDifficulty(DifficultyLevel + 1), + Enabled = { Value = string.IsNullOrEmpty(cannotIncreaseReason) }, + TooltipText = cannotIncreaseReason + }); + } + else + { + if (DifficultyLevel == 1) + { + buttonFlow.Add(new ButtonWithKeyBind(Key.Enter) + { + Text = "Retry", + TooltipText = "Are you even trying..?", + BackgroundColour = colours.Pink2, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => + { + isCertifying = false; + changeDifficulty(1); + }, + }); + } + else + { + buttonFlow.Add(new ButtonWithKeyBind(Key.Enter) + { + Text = "Begin certification at last level", + BackgroundColour = colours.Yellow, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => + { + isCertifying = true; + changeDifficulty(DifficultyLevel - 1); + }, + TooltipText = isPass + ? $"Chain {rounds_to_complete_certified} rounds to confirm your perception!" + : "You've reached your limits. Go to the previous level to complete certification!", + }); + } + } + } + + private void showCertifiedScreen() + { + Drawable background; + Drawable certifiedText; + + resultsArea.AddRange(new[] + { + background = new Box + { + Colour = overlayColourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + (certifiedText = new OsuSpriteText + { + Alpha = 0, + Font = OsuFont.TorusAlternate.With(size: 80, weight: FontWeight.Bold), + Text = "Certified!", + Blending = BlendingParameters.Additive, + }).WithEffect(new GlowEffect + { + Colour = overlayColourProvider.Colour1, + PadExtent = true + }).With(e => + { + e.Anchor = Anchor.Centre; + e.Origin = Anchor.Centre; + }), + new OsuSpriteText + { + Text = $"You should use a frame limiter with update rate of {mapDifficultyToTargetFrameRate(DifficultyLevel + 1)} Hz (or fps) for best results!", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.SemiBold), + Y = 80, + } + }); + + background.FadeInFromZero(1000, Easing.OutQuint); + + certifiedText.FadeInFromZero(500, Easing.InQuint); + + certifiedText + .ScaleTo(10) + .ScaleTo(1, 600, Easing.InQuad) + .Then() + .ScaleTo(1.05f, 10000, Easing.OutQuint); + } + + private void changeDifficulty(int difficulty) + { + Debug.Assert(difficulty > 0); + + resultsArea.Clear(); + + correctAtCurrentDifficulty = 0; + attemptsAtCurrentDifficulty = 0; + + pollingMax = 0; + lastPoll = 0; + + DifficultyLevel = difficulty; + + loadNextRound(); + } + + private void loadNextRound() + { + settings.Show(); + + attemptsAtCurrentDifficulty++; + statusText.Text = $"Level {DifficultyLevel}\nRound {attemptsAtCurrentDifficulty} of {totalRoundForNextResultsScreen}"; + + mainArea.Clear(); + + int betterSide = RNG.Next(0, 2); + + mainArea.AddRange(new[] + { + new LatencyArea(Key.Number1, betterSide == 1 ? mapDifficultyToTargetFrameRate(DifficultyLevel) : (int?)null) + { + Width = 0.5f, + VisualMode = { BindTarget = VisualMode }, + IsActiveArea = { Value = true }, + ReportUserBest = () => recordResult(betterSide == 0), + }, + new LatencyArea(Key.Number2, betterSide == 0 ? mapDifficultyToTargetFrameRate(DifficultyLevel) : (int?)null) + { + Width = 0.5f, + VisualMode = { BindTarget = VisualMode }, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + ReportUserBest = () => recordResult(betterSide == 1) + } + }); + + foreach (var area in mainArea) + { + area.IsActiveArea.BindValueChanged(active => + { + if (active.NewValue) + mainArea.Children.First(a => a != area).IsActiveArea.Value = false; + }); + } + } + + private void recordResult(bool correct) + { + // Fading this out will improve the frame rate after the first round due to less text on screen. + explanatoryText.FadeOut(500, Easing.OutQuint); + + if (correct) + correctAtCurrentDifficulty++; + + if (attemptsAtCurrentDifficulty < totalRoundForNextResultsScreen) + loadNextRound(); + else + showResults(); + } + + private static int mapDifficultyToTargetFrameRate(int difficulty) + { + switch (difficulty) + { + case 1: + return 15; + + case 2: + return 30; + + case 3: + return 45; + + case 4: + return 60; + + case 5: + return 120; + + case 6: + return 240; + + case 7: + return 480; + + case 8: + return 720; + + case 9: + return 960; + + default: + return 1000 + ((difficulty - 10) * 500); + } + } + } +} diff --git a/osu.Game/Screens/Utility/LatencyVisualMode.cs b/osu.Game/Screens/Utility/LatencyVisualMode.cs new file mode 100644 index 0000000000..cc15f79be8 --- /dev/null +++ b/osu.Game/Screens/Utility/LatencyVisualMode.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable +namespace osu.Game.Screens.Utility +{ + public enum LatencyVisualMode + { + CircleGameplay, + ScrollingGameplay, + Simple, + } +} diff --git a/osu.Game/Screens/Utility/SampleComponents/LatencyCursorContainer.cs b/osu.Game/Screens/Utility/SampleComponents/LatencyCursorContainer.cs new file mode 100644 index 0000000000..e4c2b504cb --- /dev/null +++ b/osu.Game/Screens/Utility/SampleComponents/LatencyCursorContainer.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. + +#nullable enable +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Game.Overlays; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Utility.SampleComponents +{ + public class LatencyCursorContainer : LatencySampleComponent + { + private Circle cursor = null!; + + [Resolved] + private OverlayColourProvider overlayColourProvider { get; set; } = null!; + + public LatencyCursorContainer() + { + Masking = true; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChild = cursor = new Circle + { + Size = new Vector2(40), + Origin = Anchor.Centre, + Colour = overlayColourProvider.Colour2, + }; + } + + protected override bool OnHover(HoverEvent e) => false; + + protected override void UpdateAtLimitedRate(InputState inputState) + { + cursor.Colour = inputState.Mouse.IsPressed(MouseButton.Left) ? overlayColourProvider.Content1 : overlayColourProvider.Colour2; + + if (IsActive.Value) + { + cursor.Position = ToLocalSpace(inputState.Mouse.Position); + cursor.Alpha = 1; + } + else + { + cursor.Alpha = 0; + } + } + } +} diff --git a/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs b/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs new file mode 100644 index 0000000000..a7da05fbb1 --- /dev/null +++ b/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs @@ -0,0 +1,84 @@ +// 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 osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Utility.SampleComponents +{ + public class LatencyMovableBox : LatencySampleComponent + { + private Box box = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChild = box = new Box + { + Size = new Vector2(40), + RelativePositionAxes = Axes.Both, + Position = new Vector2(0.5f), + Origin = Anchor.Centre, + Colour = OverlayColourProvider.Colour1, + }; + } + + protected override bool OnHover(HoverEvent e) => false; + + private double? lastFrameTime; + + protected override void UpdateAtLimitedRate(InputState inputState) + { + if (!IsActive.Value) + { + lastFrameTime = null; + box.Colour = OverlayColourProvider.Colour1; + return; + } + + if (lastFrameTime != null) + { + float movementAmount = (float)(Clock.CurrentTime - lastFrameTime) / 400; + + var buttons = inputState.Keyboard.Keys; + + box.Colour = buttons.HasAnyButtonPressed ? OverlayColourProvider.Content1 : OverlayColourProvider.Colour1; + + foreach (var key in buttons) + { + switch (key) + { + case Key.F: + case Key.Up: + box.Y = MathHelper.Clamp(box.Y - movementAmount, 0.1f, 0.9f); + break; + + case Key.J: + case Key.Down: + box.Y = MathHelper.Clamp(box.Y + movementAmount, 0.1f, 0.9f); + break; + + case Key.Z: + case Key.Left: + box.X = MathHelper.Clamp(box.X - movementAmount, 0.1f, 0.9f); + break; + + case Key.X: + case Key.Right: + box.X = MathHelper.Clamp(box.X + movementAmount, 0.1f, 0.9f); + break; + } + } + } + + lastFrameTime = Clock.CurrentTime; + } + } +} diff --git a/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs b/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs new file mode 100644 index 0000000000..c3233d5aa5 --- /dev/null +++ b/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.States; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Utility.SampleComponents +{ + public abstract class LatencySampleComponent : CompositeDrawable + { + protected readonly BindableDouble SampleBPM = new BindableDouble(); + protected readonly BindableDouble SampleApproachRate = new BindableDouble(); + protected readonly BindableFloat SampleVisualSpacing = new BindableFloat(); + + protected readonly BindableBool IsActive = new BindableBool(); + + private InputManager inputManager = null!; + + [Resolved] + private LatencyArea latencyArea { get; set; } = null!; + + [Resolved] + protected OverlayColourProvider OverlayColourProvider { get; private set; } = null!; + + [BackgroundDependencyLoader] + private void load(LatencyCertifierScreen latencyCertifierScreen) + { + SampleBPM.BindTo(latencyCertifierScreen.SampleBPM); + SampleApproachRate.BindTo(latencyCertifierScreen.SampleApproachRate); + SampleVisualSpacing.BindTo(latencyCertifierScreen.SampleVisualSpacing); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + IsActive.BindTo(latencyArea.IsActiveArea); + } + + protected sealed override void Update() + { + base.Update(); + UpdateAtLimitedRate(inputManager.CurrentState); + } + + protected abstract void UpdateAtLimitedRate(InputState inputState); + } +} diff --git a/osu.Game/Screens/Utility/ScrollingGameplay.cs b/osu.Game/Screens/Utility/ScrollingGameplay.cs new file mode 100644 index 0000000000..a32fd3b37f --- /dev/null +++ b/osu.Game/Screens/Utility/ScrollingGameplay.cs @@ -0,0 +1,201 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Utility.SampleComponents; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Utility +{ + public class ScrollingGameplay : LatencySampleComponent + { + private const float judgement_position = 0.8f; + private const float bar_height = 20; + + private int nextLocation; + + private readonly List hitEvents = new List(); + + private double? lastGeneratedBeatTime; + + private Container circles = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + new Box + { + Name = "judgement bar", + Colour = OverlayColourProvider.Content2, + RelativeSizeAxes = Axes.X, + RelativePositionAxes = Axes.Y, + Y = judgement_position, + Height = bar_height, + }, + circles = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }; + + SampleBPM.BindValueChanged(_ => + { + circles.Clear(); + lastGeneratedBeatTime = null; + }); + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + double beatLength = 60000 / SampleBPM.Value; + + int nextBeat = (int)(Clock.CurrentTime / beatLength) + 1; + + // We want to generate a few hit objects ahead of the current time (to allow them to animate). + double generateUpTo = (nextBeat + 2) * beatLength; + + while (lastGeneratedBeatTime == null || lastGeneratedBeatTime < generateUpTo) + { + double time = ++nextBeat * beatLength; + + if (time <= lastGeneratedBeatTime) + continue; + + newBeat(time); + lastGeneratedBeatTime = time; + } + } + + private void newBeat(double time) + { + const float columns = 4; + + float adjustedXPos = ((1f + nextLocation++ % columns) - columns / 2) / columns; + + circles.Add(new SampleNote(time) + { + RelativePositionAxes = Axes.Both, + X = 0.5f + SampleVisualSpacing.Value * (adjustedXPos * 0.5f), + Scale = new Vector2(0.4f + (0.8f * SampleVisualSpacing.Value), 1), + Hit = hit, + }); + } + + private void hit(HitEvent h) + { + hitEvents.Add(h); + } + + public class SampleNote : LatencySampleComponent + { + public HitEvent? HitEvent; + + public Action? Hit { get; set; } + + public readonly double HitTime; + + private Box box = null!; + + private const float size = 100; + private const float duration = 200; + + public SampleNote(double hitTime) + { + HitTime = hitTime; + + Origin = Anchor.Centre; + AutoSizeAxes = Axes.Both; + AlwaysPresent = true; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = OverlayColourProvider.Content1, + Size = new Vector2(size, bar_height), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!IsActive.Value) + return false; + + if (Math.Abs(Clock.CurrentTime - HitTime) > duration) + return false; + + // Allow using any key that isn't used by the latency certifier itself. + switch (e.Key) + { + case Key.Space: + case Key.Number1: + case Key.Number2: + case Key.Tab: + return false; + } + + attemptHit(); + return true; + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + if (HitEvent == null) + { + double preempt = (float)IBeatmapDifficultyInfo.DifficultyRange(SampleApproachRate.Value, 1800, 1200, 450); + + Alpha = (float)MathHelper.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); + Y = judgement_position - (float)((HitTime - Clock.CurrentTime) / preempt); + + if (Clock.CurrentTime > HitTime + duration) + Expire(); + } + } + + private void attemptHit() => Schedule(() => + { + if (HitEvent != null) + return; + + // in case it was hit outside of display range, show immediately + // so the user isn't confused. + this.FadeIn(); + + box + .FadeOut(duration / 2) + .ScaleTo(1.5f, duration / 2); + + HitEvent = new HitEvent(Clock.CurrentTime - HitTime, HitResult.Good, new HitObject + { + HitWindows = new HitWindows(), + }, null, null); + + Hit?.Invoke(HitEvent.Value); + + this.Delay(duration).Expire(); + }); + } + } +} diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Stores/RealmArchiveModelImporter.cs index 6d1449a4b4..e7477cfffc 100644 --- a/osu.Game/Stores/RealmArchiveModelImporter.cs +++ b/osu.Game/Stores/RealmArchiveModelImporter.cs @@ -521,8 +521,7 @@ namespace osu.Game.Stores // for the best or worst, we copy and import files of a new import before checking whether // it is a duplicate. so to check if anything has changed, we can just compare all File IDs. getIDs(existing.Files).SequenceEqual(getIDs(import.Files)) && - getFilenames(existing.Files).SequenceEqual(getFilenames(import.Files)) && - checkAllFilesExist(existing); + getFilenames(existing.Files).SequenceEqual(getFilenames(import.Files)); private bool checkAllFilesExist(TModel model) => model.Files.All(f => Files.Storage.Exists(f.File.GetStoragePath())); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 63b8cf4cb5..19ec7ebcd0 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/osu.iOS.props b/osu.iOS.props index a0fafa635b..c971612f62 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,7 +61,7 @@ - + @@ -84,7 +84,7 @@ - +