diff --git a/osu.Android.props b/osu.Android.props
index 8c15ed7949..caaa83bff4 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,7 +52,7 @@
-
+
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs
new file mode 100644
index 0000000000..1aed84be10
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs
@@ -0,0 +1,175 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Timing;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Mods;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.Replays;
+using osu.Game.Rulesets.Replays;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests.Mods
+{
+ public class TestSceneOsuModSingleTap : OsuModTestScene
+ {
+ [Test]
+ public void TestInputSingular() => CreateModTest(new ModTestData
+ {
+ Mod = new OsuModSingleTap(),
+ PassCondition = () => Player.ScoreProcessor.Combo.Value == 2,
+ Autoplay = false,
+ Beatmap = new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle
+ {
+ StartTime = 500,
+ Position = new Vector2(100),
+ },
+ new HitCircle
+ {
+ StartTime = 1000,
+ Position = new Vector2(200, 100),
+ },
+ new HitCircle
+ {
+ StartTime = 1500,
+ Position = new Vector2(300, 100),
+ },
+ new HitCircle
+ {
+ StartTime = 2000,
+ Position = new Vector2(400, 100),
+ },
+ },
+ },
+ ReplayFrames = new List
+ {
+ new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton),
+ new OsuReplayFrame(501, new Vector2(100)),
+ new OsuReplayFrame(1000, new Vector2(200, 100), OsuAction.LeftButton),
+ }
+ });
+
+ [Test]
+ public void TestInputAlternating() => CreateModTest(new ModTestData
+ {
+ Mod = new OsuModSingleTap(),
+ PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1,
+ Autoplay = false,
+ Beatmap = new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle
+ {
+ StartTime = 500,
+ Position = new Vector2(100),
+ },
+ new HitCircle
+ {
+ StartTime = 1000,
+ Position = new Vector2(200, 100),
+ },
+ },
+ },
+ ReplayFrames = new List
+ {
+ new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton),
+ new OsuReplayFrame(501, new Vector2(100)),
+ new OsuReplayFrame(1000, new Vector2(200, 100), OsuAction.RightButton),
+ new OsuReplayFrame(1001, new Vector2(200, 100)),
+ new OsuReplayFrame(1500, new Vector2(300, 100), OsuAction.LeftButton),
+ new OsuReplayFrame(1501, new Vector2(300, 100)),
+ new OsuReplayFrame(2000, new Vector2(400, 100), OsuAction.RightButton),
+ new OsuReplayFrame(2001, new Vector2(400, 100)),
+ }
+ });
+
+ ///
+ /// Ensures singletapping is reset before the first hitobject after intro.
+ ///
+ [Test]
+ public void TestInputAlternatingAtIntro() => CreateModTest(new ModTestData
+ {
+ Mod = new OsuModSingleTap(),
+ PassCondition = () => Player.ScoreProcessor.Combo.Value == 1,
+ Autoplay = false,
+ Beatmap = new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle
+ {
+ StartTime = 1000,
+ Position = new Vector2(100),
+ },
+ },
+ },
+ ReplayFrames = new List
+ {
+ // first press during intro.
+ new OsuReplayFrame(500, new Vector2(200), OsuAction.LeftButton),
+ new OsuReplayFrame(501, new Vector2(200)),
+ // press different key at hitobject and ensure it has been hit.
+ new OsuReplayFrame(1000, new Vector2(100), OsuAction.RightButton),
+ }
+ });
+
+ ///
+ /// Ensures singletapping is reset before the first hitobject after a break.
+ ///
+ [Test]
+ public void TestInputAlternatingWithBreak() => CreateModTest(new ModTestData
+ {
+ Mod = new OsuModSingleTap(),
+ PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2,
+ Autoplay = false,
+ Beatmap = new Beatmap
+ {
+ Breaks = new List
+ {
+ new BreakPeriod(500, 2000),
+ },
+ HitObjects = new List
+ {
+ new HitCircle
+ {
+ StartTime = 500,
+ Position = new Vector2(100),
+ },
+ new HitCircle
+ {
+ StartTime = 2500,
+ Position = new Vector2(500, 100),
+ },
+ new HitCircle
+ {
+ StartTime = 3000,
+ Position = new Vector2(500, 100),
+ },
+ }
+ },
+ ReplayFrames = new List
+ {
+ // first press to start singletap lock.
+ new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton),
+ new OsuReplayFrame(501, new Vector2(100)),
+ // press different key after break but before hit object.
+ new OsuReplayFrame(2250, new Vector2(300, 100), OsuAction.RightButton),
+ new OsuReplayFrame(2251, new Vector2(300, 100)),
+ // press same key at second hitobject and ensure it has been hit.
+ new OsuReplayFrame(2500, new Vector2(500, 100), OsuAction.LeftButton),
+ new OsuReplayFrame(2501, new Vector2(500, 100)),
+ // press different key at third hitobject and ensure it has been missed.
+ new OsuReplayFrame(3000, new Vector2(500, 100), OsuAction.RightButton),
+ new OsuReplayFrame(3001, new Vector2(500, 100)),
+ }
+ });
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs
new file mode 100644
index 0000000000..a7aca8257b
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs
@@ -0,0 +1,114 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Graphics;
+using osu.Framework.Input.Bindings;
+using osu.Framework.Input.Events;
+using osu.Game.Beatmaps.Timing;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Rulesets.UI;
+using osu.Game.Screens.Play;
+using osu.Game.Utils;
+
+namespace osu.Game.Rulesets.Osu.Mods
+{
+ public abstract class InputBlockingMod : Mod, IApplicableToDrawableRuleset
+ {
+ public override double ScoreMultiplier => 1.0;
+ public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(OsuModCinema) };
+ public override ModType Type => ModType.Conversion;
+
+ private const double flash_duration = 1000;
+
+ private DrawableRuleset ruleset = null!;
+
+ protected OsuAction? LastAcceptedAction { get; private set; }
+
+ ///
+ /// A tracker for periods where alternate should not be forced (i.e. non-gameplay periods).
+ ///
+ ///
+ /// This is different from in that the periods here end strictly at the first object after the break, rather than the break's end time.
+ ///
+ private PeriodTracker nonGameplayPeriods = null!;
+
+ private IFrameStableClock gameplayClock = null!;
+
+ public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset)
+ {
+ ruleset = drawableRuleset;
+ drawableRuleset.KeyBindingInputManager.Add(new InputInterceptor(this));
+
+ var periods = new List();
+
+ if (drawableRuleset.Objects.Any())
+ {
+ periods.Add(new Period(int.MinValue, getValidJudgementTime(ruleset.Objects.First()) - 1));
+
+ foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks)
+ periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1));
+
+ static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh);
+ }
+
+ nonGameplayPeriods = new PeriodTracker(periods);
+
+ gameplayClock = drawableRuleset.FrameStableClock;
+ }
+
+ protected abstract bool CheckValidNewAction(OsuAction action);
+
+ private bool checkCorrectAction(OsuAction action)
+ {
+ if (nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime))
+ {
+ LastAcceptedAction = null;
+ return true;
+ }
+
+ switch (action)
+ {
+ case OsuAction.LeftButton:
+ case OsuAction.RightButton:
+ break;
+
+ // Any action which is not left or right button should be ignored.
+ default:
+ return true;
+ }
+
+ if (CheckValidNewAction(action))
+ {
+ LastAcceptedAction = action;
+ return true;
+ }
+
+ ruleset.Cursor.FlashColour(Colour4.Red, flash_duration, Easing.OutQuint);
+ return false;
+ }
+
+ private class InputInterceptor : Component, IKeyBindingHandler
+ {
+ private readonly InputBlockingMod mod;
+
+ public InputInterceptor(InputBlockingMod mod)
+ {
+ this.mod = mod;
+ }
+
+ public bool OnPressed(KeyBindingPressEvent e)
+ // if the pressed action is incorrect, block it from reaching gameplay.
+ => !mod.checkCorrectAction(e.Action);
+
+ public void OnReleased(KeyBindingReleaseEvent e)
+ {
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs
index 622d2df432..d88cb17e84 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs
@@ -1,119 +1,20 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
-using System.Collections.Generic;
using System.Linq;
-using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
-using osu.Framework.Input.Bindings;
-using osu.Framework.Input.Events;
-using osu.Game.Beatmaps.Timing;
-using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Objects;
-using osu.Game.Rulesets.Osu.Objects;
-using osu.Game.Rulesets.Scoring;
-using osu.Game.Rulesets.UI;
-using osu.Game.Screens.Play;
-using osu.Game.Utils;
namespace osu.Game.Rulesets.Osu.Mods
{
- public class OsuModAlternate : Mod, IApplicableToDrawableRuleset
+ public class OsuModAlternate : InputBlockingMod
{
public override string Name => @"Alternate";
public override string Acronym => @"AL";
public override string Description => @"Don't use the same key twice in a row!";
- public override double ScoreMultiplier => 1.0;
- public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax) };
- public override ModType Type => ModType.Conversion;
public override IconUsage? Icon => FontAwesome.Solid.Keyboard;
+ public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModSingleTap) }).ToArray();
- private const double flash_duration = 1000;
-
- ///
- /// A tracker for periods where alternate should not be forced (i.e. non-gameplay periods).
- ///
- ///
- /// This is different from in that the periods here end strictly at the first object after the break, rather than the break's end time.
- ///
- private PeriodTracker nonGameplayPeriods;
-
- private OsuAction? lastActionPressed;
- private DrawableRuleset ruleset;
-
- private IFrameStableClock gameplayClock;
-
- public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset)
- {
- ruleset = drawableRuleset;
- drawableRuleset.KeyBindingInputManager.Add(new InputInterceptor(this));
-
- var periods = new List();
-
- if (drawableRuleset.Objects.Any())
- {
- periods.Add(new Period(int.MinValue, getValidJudgementTime(ruleset.Objects.First()) - 1));
-
- foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks)
- periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1));
-
- static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh);
- }
-
- nonGameplayPeriods = new PeriodTracker(periods);
-
- gameplayClock = drawableRuleset.FrameStableClock;
- }
-
- private bool checkCorrectAction(OsuAction action)
- {
- if (nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime))
- {
- lastActionPressed = null;
- return true;
- }
-
- switch (action)
- {
- case OsuAction.LeftButton:
- case OsuAction.RightButton:
- break;
-
- // Any action which is not left or right button should be ignored.
- default:
- return true;
- }
-
- if (lastActionPressed != action)
- {
- // User alternated correctly.
- lastActionPressed = action;
- return true;
- }
-
- ruleset.Cursor.FlashColour(Colour4.Red, flash_duration, Easing.OutQuint);
- return false;
- }
-
- private class InputInterceptor : Component, IKeyBindingHandler
- {
- private readonly OsuModAlternate mod;
-
- public InputInterceptor(OsuModAlternate mod)
- {
- this.mod = mod;
- }
-
- public bool OnPressed(KeyBindingPressEvent e)
- // if the pressed action is incorrect, block it from reaching gameplay.
- => !mod.checkCorrectAction(e.Action);
-
- public void OnReleased(KeyBindingReleaseEvent e)
- {
- }
- }
+ protected override bool CheckValidNewAction(OsuAction action) => LastAcceptedAction != action;
}
}
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs
index 490b5b7a9d..c4de77b8a3 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs
@@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModAutoplay : ModAutoplay
{
- public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate) }).ToArray();
+ public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods)
=> new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs
index 656cf95e77..704b922ee5 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs
@@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModCinema : ModCinema
{
- public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate) }).ToArray();
+ public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods)
=> new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
index 2cf8c278ca..2030156f2e 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs
@@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer
{
public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things.";
- public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised), typeof(OsuModAlternate) }).ToArray();
+ public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray();
///
/// How early before a hitobject's start time to trigger a hit.
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs
new file mode 100644
index 0000000000..051ceb968c
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs
@@ -0,0 +1,18 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Linq;
+
+namespace osu.Game.Rulesets.Osu.Mods
+{
+ public class OsuModSingleTap : InputBlockingMod
+ {
+ public override string Name => @"Single Tap";
+ public override string Acronym => @"ST";
+ public override string Description => @"You must only use one key!";
+ public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAlternate) }).ToArray();
+
+ protected override bool CheckValidNewAction(OsuAction action) => LastAcceptedAction == null || LastAcceptedAction == action;
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs
index ba0ef9ec3a..302194e91a 100644
--- a/osu.Game.Rulesets.Osu/OsuRuleset.cs
+++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs
@@ -172,7 +172,7 @@ namespace osu.Game.Rulesets.Osu
new OsuModClassic(),
new OsuModRandom(),
new OsuModMirror(),
- new OsuModAlternate(),
+ new MultiMod(new OsuModAlternate(), new OsuModSingleTap())
};
case ModType.Automation:
diff --git a/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs
index de23b012c1..c887105da6 100644
--- a/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs
+++ b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs
@@ -4,9 +4,12 @@
#nullable disable
using System;
+using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
+using osu.Game.Models;
+using osu.Game.Tests.Resources;
namespace osu.Game.Tests.NonVisual
{
@@ -23,6 +26,47 @@ namespace osu.Game.Tests.NonVisual
Assert.IsTrue(ourInfo.MatchesOnlineID(otherInfo));
}
+ [Test]
+ public void TestAudioEqualityNoFile()
+ {
+ var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1);
+ var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1);
+
+ Assert.AreNotEqual(beatmapSetA, beatmapSetB);
+ Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single()));
+ }
+
+ [Test]
+ public void TestAudioEqualitySameHash()
+ {
+ var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1);
+ var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1);
+
+ addAudioFile(beatmapSetA, "abc");
+ addAudioFile(beatmapSetB, "abc");
+
+ Assert.AreNotEqual(beatmapSetA, beatmapSetB);
+ Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single()));
+ }
+
+ [Test]
+ public void TestAudioEqualityDifferentHash()
+ {
+ var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1);
+ var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1);
+
+ addAudioFile(beatmapSetA);
+ addAudioFile(beatmapSetB);
+
+ Assert.AreNotEqual(beatmapSetA, beatmapSetB);
+ Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single()));
+ }
+
+ private static void addAudioFile(BeatmapSetInfo beatmapSetInfo, string hash = null)
+ {
+ beatmapSetInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = hash ?? Guid.NewGuid().ToString() }, "audio.mp3"));
+ }
+
[Test]
public void TestDatabasedWithDatabased()
{
diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs
index 91d4eb70e8..41404b2636 100644
--- a/osu.Game.Tests/Resources/TestResources.cs
+++ b/osu.Game.Tests/Resources/TestResources.cs
@@ -134,6 +134,7 @@ namespace osu.Game.Tests.Resources
DifficultyName = $"{version} {beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})",
StarRating = diff,
Length = length,
+ BeatmapSet = beatmapSet,
BPM = bpm,
Hash = Guid.NewGuid().ToString().ComputeMD5Hash(),
Ruleset = rulesetInfo,
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
index f565ca3ef4..6ad6f0b299 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
@@ -6,6 +6,7 @@ using System.IO;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
+using osu.Framework.Audio.Track;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Screens;
@@ -103,6 +104,8 @@ namespace osu.Game.Tests.Visual.Editing
*/
public void TestAddAudioTrack()
{
+ AddAssert("track is virtual", () => Beatmap.Value.Track is TrackVirtual);
+
AddAssert("switch track to real track", () =>
{
var setup = Editor.ChildrenOfType().First();
@@ -131,6 +134,7 @@ namespace osu.Game.Tests.Visual.Editing
}
});
+ AddAssert("track is not virtual", () => Beatmap.Value.Track is not TrackVirtual);
AddAssert("track length changed", () => Beatmap.Value.Track.Length > 60000);
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs
index bfc06c0ee0..5ec9e88728 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
@@ -59,6 +60,20 @@ namespace osu.Game.Tests.Visual.Gameplay
protected override bool AllowFail => false;
+ [Test]
+ public void TestLastPlayedUpdated()
+ {
+ DateTimeOffset? getLastPlayed() => Realm.Run(r => r.Find(Beatmap.Value.BeatmapInfo.ID)?.LastPlayed);
+
+ AddStep("set no custom ruleset", () => customRuleset = null);
+ AddAssert("last played is null", () => getLastPlayed() == null);
+
+ CreateTest();
+
+ AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning);
+ AddUntilStep("wait for last played to update", () => getLastPlayed() != null);
+ }
+
[Test]
public void TestScoreStoredLocally()
{
diff --git a/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs
index 0fc3646585..b088670caa 100644
--- a/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs
+++ b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs
@@ -12,7 +12,8 @@ namespace osu.Game.Tournament.Components
{
public class TournamentSpriteTextWithBackground : CompositeDrawable
{
- protected readonly TournamentSpriteText Text;
+ public readonly TournamentSpriteText Text;
+
protected readonly Box Background;
public TournamentSpriteTextWithBackground(string text = "")
diff --git a/osu.Game.Tournament/Models/SeedingBeatmap.cs b/osu.Game.Tournament/Models/SeedingBeatmap.cs
index 03beb7ca9a..fb0e20556c 100644
--- a/osu.Game.Tournament/Models/SeedingBeatmap.cs
+++ b/osu.Game.Tournament/Models/SeedingBeatmap.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using Newtonsoft.Json;
using osu.Framework.Bindables;
@@ -13,7 +11,7 @@ namespace osu.Game.Tournament.Models
public int ID;
[JsonProperty("BeatmapInfo")]
- public TournamentBeatmap Beatmap;
+ public TournamentBeatmap? Beatmap;
public long Score;
diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs
index bb187c9e67..1eceddd871 100644
--- a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs
+++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs
@@ -16,6 +16,10 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components
{
private readonly TeamScore score;
+ private readonly TournamentSpriteTextWithBackground teamText;
+
+ private readonly Bindable teamName = new Bindable("???");
+
private bool showScore;
public bool ShowScore
@@ -93,7 +97,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components
}
}
},
- new TournamentSpriteTextWithBackground(team?.FullName.Value ?? "???")
+ teamText = new TournamentSpriteTextWithBackground
{
Scale = new Vector2(0.5f),
Origin = anchor,
@@ -113,6 +117,11 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components
updateDisplay();
FinishTransforms(true);
+
+ if (Team != null)
+ teamName.BindTo(Team.FullName);
+
+ teamName.BindValueChanged(name => teamText.Text.Text = name.NewValue, true);
}
private void updateDisplay()
diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs
index ed11f097ed..5ee57e9271 100644
--- a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs
+++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs
@@ -42,6 +42,8 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components
currentMatch.BindTo(ladder.CurrentMatch);
currentMatch.BindValueChanged(matchChanged);
+ currentTeam.BindValueChanged(teamChanged);
+
updateMatch();
}
@@ -67,7 +69,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components
// team may change to same team, which means score is not in a good state.
// thus we handle this manually.
- teamChanged(currentTeam.Value);
+ currentTeam.TriggerChange();
}
protected override bool OnMouseDown(MouseDownEvent e)
@@ -88,11 +90,11 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components
return base.OnMouseDown(e);
}
- private void teamChanged(TournamentTeam team)
+ private void teamChanged(ValueChangedEvent team)
{
InternalChildren = new Drawable[]
{
- teamDisplay = new TeamDisplay(team, teamColour, currentTeamScore, currentMatch.Value?.PointsToWin ?? 0),
+ teamDisplay = new TeamDisplay(team.NewValue, teamColour, currentTeamScore, currentMatch.Value?.PointsToWin ?? 0),
};
}
}
diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs
index f0eda5672a..1fdf616e34 100644
--- a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs
+++ b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs
@@ -53,6 +53,9 @@ namespace osu.Game.Tournament.Screens.Ladder.Components
editorInfo.Selected.ValueChanged += selection =>
{
+ // ensure any ongoing edits are committed out to the *current* selection before changing to a new one.
+ GetContainingInputManager().TriggerFocusContention(null);
+
roundDropdown.Current = selection.NewValue?.Round;
losersCheckbox.Current = selection.NewValue?.Losers;
dateTimeBox.Current = selection.NewValue?.Date;
diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs
index 719e0384d3..9262cab098 100644
--- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs
+++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs
@@ -3,6 +3,7 @@
#nullable disable
+using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -69,7 +70,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro
currentTeam.BindValueChanged(teamChanged, true);
}
- private void teamChanged(ValueChangedEvent team)
+ private void teamChanged(ValueChangedEvent team) => Scheduler.AddOnce(() =>
{
if (team.NewValue == null)
{
@@ -78,7 +79,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro
}
showTeam(team.NewValue);
- }
+ });
protected override void CurrentMatchChanged(ValueChangedEvent match)
{
@@ -120,8 +121,14 @@ namespace osu.Game.Tournament.Screens.TeamIntro
foreach (var seeding in team.SeedingResults)
{
fill.Add(new ModRow(seeding.Mod.Value, seeding.Seed.Value));
+
foreach (var beatmap in seeding.Beatmaps)
+ {
+ if (beatmap.Beatmap == null)
+ continue;
+
fill.Add(new BeatmapScoreRow(beatmap));
+ }
}
}
@@ -129,6 +136,8 @@ namespace osu.Game.Tournament.Screens.TeamIntro
{
public BeatmapScoreRow(SeedingBeatmap beatmap)
{
+ Debug.Assert(beatmap.Beatmap != null);
+
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
@@ -157,7 +166,8 @@ namespace osu.Game.Tournament.Screens.TeamIntro
Children = new Drawable[]
{
new TournamentSpriteText { Text = beatmap.Score.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Width = 80 },
- new TournamentSpriteText { Text = "#" + beatmap.Seed.Value.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) },
+ new TournamentSpriteText
+ { Text = "#" + beatmap.Seed.Value.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) },
}
},
};
diff --git a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs
index 07557674e8..ac54ff58f5 100644
--- a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs
+++ b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs
@@ -66,7 +66,7 @@ namespace osu.Game.Tournament.Screens.TeamWin
private bool firstDisplay = true;
- private void update() => Schedule(() =>
+ private void update() => Scheduler.AddOnce(() =>
{
var match = CurrentMatch.Value;
diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs
index 346bf86818..45d76259fc 100644
--- a/osu.Game/Beatmaps/BeatmapInfo.cs
+++ b/osu.Game/Beatmaps/BeatmapInfo.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using Newtonsoft.Json;
@@ -110,6 +111,11 @@ namespace osu.Game.Beatmaps
public bool SamplesMatchPlaybackRate { get; set; } = true;
+ ///
+ /// The time at which this beatmap was last played by the local user.
+ ///
+ public DateTimeOffset? LastPlayed { get; set; }
+
///
/// The ratio of distance travelled per time unit.
/// Generally used to decouple the spacing between hit objects from the enforced "velocity" of the beatmap (see ).
@@ -151,14 +157,23 @@ namespace osu.Game.Beatmaps
public bool AudioEquals(BeatmapInfo? other) => other != null
&& BeatmapSet != null
&& other.BeatmapSet != null
- && BeatmapSet.Hash == other.BeatmapSet.Hash
- && Metadata.AudioFile == other.Metadata.AudioFile;
+ && compareFiles(this, other, m => m.AudioFile);
public bool BackgroundEquals(BeatmapInfo? other) => other != null
&& BeatmapSet != null
&& other.BeatmapSet != null
- && BeatmapSet.Hash == other.BeatmapSet.Hash
- && Metadata.BackgroundFile == other.Metadata.BackgroundFile;
+ && compareFiles(this, other, m => m.BackgroundFile);
+
+ private static bool compareFiles(BeatmapInfo x, BeatmapInfo y, Func getFilename)
+ {
+ Debug.Assert(x.BeatmapSet != null);
+ Debug.Assert(y.BeatmapSet != null);
+
+ string? fileHashX = x.BeatmapSet.Files.FirstOrDefault(f => f.Filename == getFilename(x.BeatmapSet.Metadata))?.File.Hash;
+ string? fileHashY = y.BeatmapSet.Files.FirstOrDefault(f => f.Filename == getFilename(y.BeatmapSet.Metadata))?.File.Hash;
+
+ return fileHashX == fileHashY;
+ }
IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata;
IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet;
diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 8cf57b802b..02b5a51f1f 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -58,8 +58,9 @@ namespace osu.Game.Database
/// 12 2021-11-24 Add Status to RealmBeatmapSet.
/// 13 2022-01-13 Final migration of beatmaps and scores to realm (multiple new storage fields).
/// 14 2022-03-01 Added BeatmapUserSettings to BeatmapInfo.
+ /// 15 2022-07-13 Added LastPlayed to BeatmapInfo.
///
- private const int schema_version = 14;
+ private const int schema_version = 15;
///
/// Lock object which is held during sections, blocking realm retrieval during blocking periods.
diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs
index 89bdd09f0d..368ac56850 100644
--- a/osu.Game/IO/OsuStorage.cs
+++ b/osu.Game/IO/OsuStorage.cs
@@ -94,6 +94,8 @@ namespace osu.Game.IO
error = OsuStorageError.None;
Storage lastStorage = UnderlyingStorage;
+ Logger.Log($"Attempting to use custom storage location {CustomStoragePath}");
+
try
{
Storage userStorage = host.GetStorage(CustomStoragePath);
@@ -102,6 +104,7 @@ namespace osu.Game.IO
error = OsuStorageError.AccessibleButEmpty;
ChangeTargetStorage(userStorage);
+ Logger.Log($"Storage successfully changed to {CustomStoragePath}.");
}
catch
{
@@ -109,6 +112,9 @@ namespace osu.Game.IO
ChangeTargetStorage(lastStorage);
}
+ if (error != OsuStorageError.None)
+ Logger.Log($"Custom storage location could not be used ({error}).");
+
return error == OsuStorageError.None;
}
diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs
index 2b763415cd..48576b81e2 100644
--- a/osu.Game/Screens/Edit/Editor.cs
+++ b/osu.Game/Screens/Edit/Editor.cs
@@ -186,7 +186,7 @@ namespace osu.Game.Screens.Edit
loadableBeatmap = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value);
// required so we can get the track length in EditorClock.
- // this is safe as nothing has yet got a reference to this new beatmap.
+ // this is ONLY safe because the track being provided is a `TrackVirtual` which we don't really care about disposing.
loadableBeatmap.LoadTrack();
// this is a bit haphazard, but guards against setting the lease Beatmap bindable if
diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs
index 87e640badc..fd230a97bc 100644
--- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs
+++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs
@@ -35,7 +35,13 @@ namespace osu.Game.Screens.Edit.GameplayTest
ScoreProcessor.HasCompleted.BindValueChanged(completed =>
{
if (completed.NewValue)
- Scheduler.AddDelayed(this.Exit, RESULTS_DISPLAY_DELAY);
+ {
+ Scheduler.AddDelayed(() =>
+ {
+ if (this.IsCurrentScreen())
+ this.Exit();
+ }, RESULTS_DISPLAY_DELAY);
+ }
});
}
diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs
index c916791eaa..ad63925b93 100644
--- a/osu.Game/Screens/Play/SubmittingPlayer.cs
+++ b/osu.Game/Screens/Play/SubmittingPlayer.cs
@@ -11,6 +11,8 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Logging;
using osu.Framework.Screens;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring;
@@ -117,6 +119,23 @@ namespace osu.Game.Screens.Play
await submitScore(score).ConfigureAwait(false);
}
+ [Resolved]
+ private RealmAccess realm { get; set; }
+
+ protected override void StartGameplay()
+ {
+ base.StartGameplay();
+
+ // User expectation is that last played should be updated when entering the gameplay loop
+ // from multiplayer / playlists / solo.
+ realm.WriteAsync(r =>
+ {
+ var realmBeatmap = r.Find(Beatmap.Value.BeatmapInfo.ID);
+ if (realmBeatmap != null)
+ realmBeatmap.LastPlayed = DateTimeOffset.Now;
+ });
+ }
+
public override bool OnExiting(ScreenExitEvent e)
{
bool exiting = base.OnExiting(e);
diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs
index 36ec536780..94d911692c 100644
--- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs
+++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs
@@ -81,6 +81,9 @@ namespace osu.Game.Screens.Select.Carousel
case SortMode.DateAdded:
return otherSet.BeatmapSet.DateAdded.CompareTo(BeatmapSet.DateAdded);
+ case SortMode.LastPlayed:
+ return -compareUsingAggregateMax(otherSet, b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds());
+
case SortMode.BPM:
return compareUsingAggregateMax(otherSet, b => b.BPM);
diff --git a/osu.Game/Screens/Select/Filter/SortMode.cs b/osu.Game/Screens/Select/Filter/SortMode.cs
index 48f774393e..4227114618 100644
--- a/osu.Game/Screens/Select/Filter/SortMode.cs
+++ b/osu.Game/Screens/Select/Filter/SortMode.cs
@@ -23,6 +23,9 @@ namespace osu.Game.Screens.Select.Filter
[Description("Date Added")]
DateAdded,
+ [Description("Last Played")]
+ LastPlayed,
+
[LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingDifficulty))]
Difficulty,
diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs
index 2732b5baa8..c938f58984 100644
--- a/osu.Game/Screens/Select/FooterButtonMods.cs
+++ b/osu.Game/Screens/Select/FooterButtonMods.cs
@@ -68,7 +68,7 @@ namespace osu.Game.Screens.Select
Current.BindValueChanged(_ => updateMultiplierText(), true);
}
- private void updateMultiplierText()
+ private void updateMultiplierText() => Schedule(() =>
{
double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1;
@@ -85,6 +85,6 @@ namespace osu.Game.Screens.Select
modDisplay.FadeIn();
else
modDisplay.FadeOut();
- }
+ });
}
}
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 1251ab800b..355fd5f458 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 c38bb548bf..fcf71f3ab0 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -61,7 +61,7 @@
-
+
@@ -84,7 +84,7 @@
-
+