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 @@
-
+