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/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs
index 757dfff2b7..1797c82fb9 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs
@@ -18,6 +18,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Cursor;
using osu.Game.Models;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
@@ -195,12 +196,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestDownloadButtonHiddenWhenBeatmapExists()
{
- var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo;
Live imported = null;
- Debug.Assert(beatmap.BeatmapSet != null);
+ AddStep("import beatmap", () =>
+ {
+ var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo;
- AddStep("import beatmap", () => imported = manager.Import(beatmap.BeatmapSet));
+ Debug.Assert(beatmap.BeatmapSet != null);
+ imported = manager.Import(beatmap.BeatmapSet);
+ });
createPlaylistWithBeatmaps(() => imported.PerformRead(s => s.Beatmaps.Detach()));
@@ -245,40 +249,35 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestExpiredItems()
{
- AddStep("create playlist", () =>
+ createPlaylist(p =>
{
- Child = playlist = new TestPlaylist
+ p.Items.Clear();
+ p.Items.AddRange(new[]
{
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(500, 300),
- Items =
+ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo)
{
- new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo)
+ ID = 0,
+ RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
+ Expired = true,
+ RequiredMods = new[]
{
- ID = 0,
- RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
- Expired = true,
- RequiredMods = new[]
- {
- new APIMod(new OsuModHardRock()),
- new APIMod(new OsuModDoubleTime()),
- new APIMod(new OsuModAutoplay())
- }
- },
- new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo)
+ new APIMod(new OsuModHardRock()),
+ new APIMod(new OsuModDoubleTime()),
+ new APIMod(new OsuModAutoplay())
+ }
+ },
+ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo)
+ {
+ ID = 1,
+ RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
+ RequiredMods = new[]
{
- ID = 1,
- RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
- RequiredMods = new[]
- {
- new APIMod(new OsuModHardRock()),
- new APIMod(new OsuModDoubleTime()),
- new APIMod(new OsuModAutoplay())
- }
+ new APIMod(new OsuModHardRock()),
+ new APIMod(new OsuModDoubleTime()),
+ new APIMod(new OsuModAutoplay())
}
}
- };
+ });
});
AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded));
@@ -321,19 +320,44 @@ namespace osu.Game.Tests.Visual.Multiplayer
=> AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible",
() => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible);
+ private void createPlaylistWithBeatmaps(Func> beatmaps) => createPlaylist(p =>
+ {
+ int index = 0;
+
+ p.Items.Clear();
+
+ foreach (var b in beatmaps())
+ {
+ p.Items.Add(new PlaylistItem(b)
+ {
+ ID = index++,
+ OwnerID = 2,
+ RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
+ RequiredMods = new[]
+ {
+ new APIMod(new OsuModHardRock()),
+ new APIMod(new OsuModDoubleTime()),
+ new APIMod(new OsuModAutoplay())
+ }
+ });
+ }
+ });
+
private void createPlaylist(Action setupPlaylist = null)
{
AddStep("create playlist", () =>
{
- Child = playlist = new TestPlaylist
+ Child = new OsuContextMenuContainer
{
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(500, 300)
+ RelativeSizeAxes = Axes.Both,
+ Child = playlist = new TestPlaylist
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(500, 300)
+ }
};
- setupPlaylist?.Invoke(playlist);
-
for (int i = 0; i < 20; i++)
{
playlist.Items.Add(new PlaylistItem(i % 2 == 1
@@ -360,39 +384,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
});
}
- });
- AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded));
- }
-
- private void createPlaylistWithBeatmaps(Func> beatmaps)
- {
- AddStep("create playlist", () =>
- {
- Child = playlist = new TestPlaylist
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(500, 300)
- };
-
- int index = 0;
-
- foreach (var b in beatmaps())
- {
- playlist.Items.Add(new PlaylistItem(b)
- {
- ID = index++,
- OwnerID = 2,
- RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
- RequiredMods = new[]
- {
- new APIMod(new OsuModHardRock()),
- new APIMod(new OsuModDoubleTime()),
- new APIMod(new OsuModAutoplay())
- }
- });
- }
+ setupPlaylist?.Invoke(playlist);
});
AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded));
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
index a70dfd78c5..edd1491865 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
@@ -9,6 +9,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Framework.Utils;
+using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online;
using osu.Game.Online.API.Requests.Responses;
@@ -368,12 +369,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
ParticipantsList? participantsList = null;
- AddStep("create new list", () => Child = participantsList = new ParticipantsList
+ AddStep("create new list", () => Child = new OsuContextMenuContainer
{
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- RelativeSizeAxes = Axes.Y,
- Size = new Vector2(380, 0.7f)
+ RelativeSizeAxes = Axes.Both,
+ Child = participantsList = new ParticipantsList
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ RelativeSizeAxes = Axes.Y,
+ Size = new Vector2(380, 0.7f)
+ }
});
AddUntilStep("wait for list to load", () => participantsList?.IsLoaded == true);
diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs
index 16a34e996f..be03328caa 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs
@@ -18,6 +18,7 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapSet.Scores;
using osu.Game.Rulesets.Osu.Mods;
+using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Users;
using osuTK.Graphics;
@@ -146,12 +147,12 @@ namespace osu.Game.Tests.Visual.Online
{
var scores = new APIScoresCollection
{
- Scores = new List
+ Scores = new List
{
- new APIScore
+ new SoloScoreInfo
{
- Date = DateTimeOffset.Now,
- OnlineID = onlineID++,
+ EndedAt = DateTimeOffset.Now,
+ ID = onlineID++,
User = new APIUser
{
Id = 6602580,
@@ -175,10 +176,10 @@ namespace osu.Game.Tests.Visual.Online
TotalScore = 1234567890,
Accuracy = 1,
},
- new APIScore
+ new SoloScoreInfo
{
- Date = DateTimeOffset.Now,
- OnlineID = onlineID++,
+ EndedAt = DateTimeOffset.Now,
+ ID = onlineID++,
User = new APIUser
{
Id = 4608074,
@@ -201,10 +202,10 @@ namespace osu.Game.Tests.Visual.Online
TotalScore = 1234789,
Accuracy = 0.9997,
},
- new APIScore
+ new SoloScoreInfo
{
- Date = DateTimeOffset.Now,
- OnlineID = onlineID++,
+ EndedAt = DateTimeOffset.Now,
+ ID = onlineID++,
User = new APIUser
{
Id = 1014222,
@@ -226,10 +227,10 @@ namespace osu.Game.Tests.Visual.Online
TotalScore = 12345678,
Accuracy = 0.9854,
},
- new APIScore
+ new SoloScoreInfo
{
- Date = DateTimeOffset.Now,
- OnlineID = onlineID++,
+ EndedAt = DateTimeOffset.Now,
+ ID = onlineID++,
User = new APIUser
{
Id = 1541390,
@@ -250,10 +251,10 @@ namespace osu.Game.Tests.Visual.Online
TotalScore = 1234567,
Accuracy = 0.8765,
},
- new APIScore
+ new SoloScoreInfo
{
- Date = DateTimeOffset.Now,
- OnlineID = onlineID++,
+ EndedAt = DateTimeOffset.Now,
+ ID = onlineID++,
User = new APIUser
{
Id = 7151382,
@@ -275,12 +276,12 @@ namespace osu.Game.Tests.Visual.Online
foreach (var s in scores.Scores)
{
- s.Statistics = new Dictionary
+ s.Statistics = new Dictionary
{
- { "count_300", RNG.Next(2000) },
- { "count_100", RNG.Next(2000) },
- { "count_50", RNG.Next(2000) },
- { "count_miss", RNG.Next(2000) }
+ { HitResult.Great, RNG.Next(2000) },
+ { HitResult.Ok, RNG.Next(2000) },
+ { HitResult.Meh, RNG.Next(2000) },
+ { HitResult.Miss, RNG.Next(2000) }
};
}
@@ -289,10 +290,10 @@ namespace osu.Game.Tests.Visual.Online
private APIScoreWithPosition createUserBest() => new APIScoreWithPosition
{
- Score = new APIScore
+ Score = new SoloScoreInfo
{
- Date = DateTimeOffset.Now,
- OnlineID = onlineID++,
+ EndedAt = DateTimeOffset.Now,
+ ID = onlineID++,
User = new APIUser
{
Id = 7151382,
diff --git a/osu.Game.Tournament/SaveChangesOverlay.cs b/osu.Game.Tournament/SaveChangesOverlay.cs
new file mode 100644
index 0000000000..b5e08fc005
--- /dev/null
+++ b/osu.Game.Tournament/SaveChangesOverlay.cs
@@ -0,0 +1,101 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Threading.Tasks;
+using osu.Framework.Allocation;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Game.Graphics;
+using osu.Game.Online.Multiplayer;
+using osuTK;
+
+namespace osu.Game.Tournament
+{
+ internal class SaveChangesOverlay : CompositeDrawable
+ {
+ [Resolved]
+ private TournamentGame tournamentGame { get; set; } = null!;
+
+ private string? lastSerialisedLadder;
+ private readonly TourneyButton saveChangesButton;
+
+ public SaveChangesOverlay()
+ {
+ RelativeSizeAxes = Axes.Both;
+
+ InternalChild = new Container
+ {
+ Anchor = Anchor.BottomRight,
+ Origin = Anchor.BottomRight,
+ Position = new Vector2(5),
+ CornerRadius = 10,
+ Masking = true,
+ AutoSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ Colour = OsuColour.Gray(0.2f),
+ RelativeSizeAxes = Axes.Both,
+ },
+ saveChangesButton = new TourneyButton
+ {
+ Text = "Save Changes",
+ Width = 140,
+ Height = 50,
+ Padding = new MarginPadding
+ {
+ Top = 10,
+ Left = 10,
+ },
+ Margin = new MarginPadding
+ {
+ Right = 10,
+ Bottom = 10,
+ },
+ Action = saveChanges,
+ // Enabled = { Value = false },
+ },
+ }
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ scheduleNextCheck();
+ }
+
+ private async Task checkForChanges()
+ {
+ string serialisedLadder = await Task.Run(() => tournamentGame.GetSerialisedLadder());
+
+ // If a save hasn't been triggered by the user yet, populate the initial value
+ lastSerialisedLadder ??= serialisedLadder;
+
+ if (lastSerialisedLadder != serialisedLadder && !saveChangesButton.Enabled.Value)
+ {
+ saveChangesButton.Enabled.Value = true;
+ saveChangesButton.Background
+ .FadeColour(saveChangesButton.BackgroundColour.Lighten(0.5f), 500, Easing.In).Then()
+ .FadeColour(saveChangesButton.BackgroundColour, 500, Easing.Out)
+ .Loop();
+ }
+
+ scheduleNextCheck();
+ }
+
+ private void scheduleNextCheck() => Scheduler.AddDelayed(() => checkForChanges().FireAndForget(), 1000);
+
+ private void saveChanges()
+ {
+ tournamentGame.SaveChanges();
+ lastSerialisedLadder = tournamentGame.GetSerialisedLadder();
+
+ saveChangesButton.Enabled.Value = false;
+ saveChangesButton.Background.FadeColour(saveChangesButton.BackgroundColour, 500);
+ }
+ }
+}
diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs
index 537fbfc038..7d67bfa759 100644
--- a/osu.Game.Tournament/TournamentGame.cs
+++ b/osu.Game.Tournament/TournamentGame.cs
@@ -11,8 +11,6 @@ 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.Shapes;
using osu.Framework.Input.Handlers.Mouse;
using osu.Framework.Logging;
using osu.Framework.Platform;
@@ -20,11 +18,11 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Tournament.Models;
-using osuTK;
using osuTK.Graphics;
namespace osu.Game.Tournament
{
+ [Cached]
public class TournamentGame : TournamentGameBase
{
public static ColourInfo GetTeamColour(TeamColour teamColour) => teamColour == TeamColour.Red ? COLOUR_RED : COLOUR_BLUE;
@@ -78,40 +76,9 @@ namespace osu.Game.Tournament
LoadComponentsAsync(new[]
{
- new Container
+ new SaveChangesOverlay
{
- CornerRadius = 10,
Depth = float.MinValue,
- Position = new Vector2(5),
- Masking = true,
- AutoSizeAxes = Axes.Both,
- Anchor = Anchor.BottomRight,
- Origin = Anchor.BottomRight,
- Children = new Drawable[]
- {
- new Box
- {
- Colour = OsuColour.Gray(0.2f),
- RelativeSizeAxes = Axes.Both,
- },
- new TourneyButton
- {
- Text = "Save Changes",
- Width = 140,
- Height = 50,
- Padding = new MarginPadding
- {
- Top = 10,
- Left = 10,
- },
- Margin = new MarginPadding
- {
- Right = 10,
- Bottom = 10,
- },
- Action = SaveChanges,
- },
- }
},
heightWarning = new WarningBox("Please make the window wider")
{
diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs
index 75c9f17d4c..f2a35ea5b3 100644
--- a/osu.Game.Tournament/TournamentGameBase.cs
+++ b/osu.Game.Tournament/TournamentGameBase.cs
@@ -295,7 +295,7 @@ namespace osu.Game.Tournament
}
}
- protected virtual void SaveChanges()
+ public void SaveChanges()
{
if (!bracketLoadTaskCompletionSource.Task.IsCompletedSuccessfully)
{
@@ -311,7 +311,16 @@ namespace osu.Game.Tournament
.ToList();
// Serialise before opening stream for writing, so if there's a failure it will leave the file in the previous state.
- string serialisedLadder = JsonConvert.SerializeObject(ladder,
+ string serialisedLadder = GetSerialisedLadder();
+
+ using (var stream = storage.CreateFileSafely(BRACKET_FILENAME))
+ using (var sw = new StreamWriter(stream))
+ sw.Write(serialisedLadder);
+ }
+
+ public string GetSerialisedLadder()
+ {
+ return JsonConvert.SerializeObject(ladder,
new JsonSerializerSettings
{
Formatting = Formatting.Indented,
@@ -319,10 +328,6 @@ namespace osu.Game.Tournament
DefaultValueHandling = DefaultValueHandling.Ignore,
Converters = new JsonConverter[] { new JsonPointConverter() }
});
-
- using (var stream = storage.CreateFileSafely(BRACKET_FILENAME))
- using (var sw = new StreamWriter(stream))
- sw.Write(serialisedLadder);
}
protected override UserInputManager CreateUserInputManager() => new TournamentInputManager();
diff --git a/osu.Game.Tournament/TourneyButton.cs b/osu.Game.Tournament/TourneyButton.cs
index f5a82771f5..f1b14df783 100644
--- a/osu.Game.Tournament/TourneyButton.cs
+++ b/osu.Game.Tournament/TourneyButton.cs
@@ -3,12 +3,15 @@
#nullable disable
+using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Tournament
{
public class TourneyButton : OsuButton
{
+ public new Box Background => base.Background;
+
public TourneyButton()
: base(null)
{
diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs
index 92f1fc17d5..3e4d01a9a3 100644
--- a/osu.Game/Beatmaps/BeatmapImporter.cs
+++ b/osu.Game/Beatmaps/BeatmapImporter.cs
@@ -80,9 +80,8 @@ namespace osu.Game.Beatmaps
if (beatmapSet.OnlineID > 0)
{
- var existingSetWithSameOnlineID = realm.All().SingleOrDefault(b => b.OnlineID == beatmapSet.OnlineID);
-
- if (existingSetWithSameOnlineID != null)
+ // OnlineID should really be unique, but to avoid catastrophic failure let's iterate just to be sure.
+ foreach (var existingSetWithSameOnlineID in realm.All().Where(b => b.OnlineID == beatmapSet.OnlineID))
{
existingSetWithSameOnlineID.DeletePending = true;
existingSetWithSameOnlineID.OnlineID = -1;
@@ -90,7 +89,7 @@ namespace osu.Game.Beatmaps
foreach (var b in existingSetWithSameOnlineID.Beatmaps)
b.OnlineID = -1;
- LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineID ({beatmapSet.OnlineID}). It will be deleted.");
+ LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineID ({beatmapSet.OnlineID}). It will be disassociated and marked for deletion.");
}
}
}
diff --git a/osu.Game/Collections/CollectionToggleMenuItem.cs b/osu.Game/Collections/CollectionToggleMenuItem.cs
new file mode 100644
index 0000000000..f2b10305b8
--- /dev/null
+++ b/osu.Game/Collections/CollectionToggleMenuItem.cs
@@ -0,0 +1,23 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Beatmaps;
+using osu.Game.Graphics.UserInterface;
+
+namespace osu.Game.Collections
+{
+ public class CollectionToggleMenuItem : ToggleMenuItem
+ {
+ public CollectionToggleMenuItem(BeatmapCollection collection, IBeatmapInfo beatmap)
+ : base(collection.Name.Value, MenuItemType.Standard, state =>
+ {
+ if (state)
+ collection.BeatmapHashes.Add(beatmap.MD5Hash);
+ else
+ collection.BeatmapHashes.Remove(beatmap.MD5Hash);
+ })
+ {
+ State.Value = collection.BeatmapHashes.Contains(beatmap.MD5Hash);
+ }
+ }
+}
diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs
index 088dc56701..43cea7fb97 100644
--- a/osu.Game/Online/API/APIAccess.cs
+++ b/osu.Game/Online/API/APIAccess.cs
@@ -38,7 +38,7 @@ namespace osu.Game.Online.API
public string WebsiteRootUrl { get; }
- public int APIVersion => 20220217; // We may want to pull this from the game version eventually.
+ public int APIVersion => 20220705; // We may want to pull this from the game version eventually.
public Exception LastLoginError { get; private set; }
diff --git a/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs b/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs
index 8bd54f889d..494826f534 100644
--- a/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs
+++ b/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs
@@ -16,11 +16,11 @@ namespace osu.Game.Online.API.Requests.Responses
public int? Position;
[JsonProperty(@"score")]
- public APIScore Score;
+ public SoloScoreInfo Score;
public ScoreInfo CreateScoreInfo(RulesetStore rulesets, BeatmapInfo beatmap = null)
{
- var score = Score.CreateScoreInfo(rulesets, beatmap);
+ var score = Score.ToScoreInfo(rulesets, beatmap);
score.Position = Position;
return score;
}
diff --git a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs
index 9c8a38c63a..38c67d92f4 100644
--- a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs
+++ b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs
@@ -11,7 +11,7 @@ namespace osu.Game.Online.API.Requests.Responses
public class APIScoresCollection
{
[JsonProperty(@"scores")]
- public List Scores;
+ public List Scores;
[JsonProperty(@"userScore")]
public APIScoreWithPosition UserScore;
diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs
new file mode 100644
index 0000000000..b70da194a5
--- /dev/null
+++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs
@@ -0,0 +1,143 @@
+// 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 Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Scoring;
+
+namespace osu.Game.Online.API.Requests.Responses
+{
+ [Serializable]
+ public class SoloScoreInfo : IHasOnlineID
+ {
+ [JsonProperty("replay")]
+ public bool HasReplay { get; set; }
+
+ [JsonProperty("beatmap_id")]
+ public int BeatmapID { get; set; }
+
+ [JsonProperty("ruleset_id")]
+ public int RulesetID { get; set; }
+
+ [JsonProperty("build_id")]
+ public int? BuildID { get; set; }
+
+ [JsonProperty("passed")]
+ public bool Passed { get; set; }
+
+ [JsonProperty("total_score")]
+ public int TotalScore { get; set; }
+
+ [JsonProperty("accuracy")]
+ public double Accuracy { get; set; }
+
+ [JsonProperty("user_id")]
+ public int UserID { get; set; }
+
+ // TODO: probably want to update this column to match user stats (short)?
+ [JsonProperty("max_combo")]
+ public int MaxCombo { get; set; }
+
+ [JsonConverter(typeof(StringEnumConverter))]
+ [JsonProperty("rank")]
+ public ScoreRank Rank { get; set; }
+
+ [JsonProperty("started_at")]
+ public DateTimeOffset? StartedAt { get; set; }
+
+ [JsonProperty("ended_at")]
+ public DateTimeOffset? EndedAt { get; set; }
+
+ [JsonProperty("mods")]
+ public APIMod[] Mods { get; set; } = Array.Empty();
+
+ [JsonIgnore]
+ [JsonProperty("created_at")]
+ public DateTimeOffset CreatedAt { get; set; }
+
+ [JsonIgnore]
+ [JsonProperty("updated_at")]
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ [JsonIgnore]
+ [JsonProperty("deleted_at")]
+ public DateTimeOffset? DeletedAt { get; set; }
+
+ [JsonProperty("statistics")]
+ public Dictionary Statistics { get; set; } = new Dictionary();
+
+ #region osu-web API additions (not stored to database).
+
+ [JsonProperty("id")]
+ public long? ID { get; set; }
+
+ [JsonProperty("user")]
+ public APIUser? User { get; set; }
+
+ [JsonProperty("pp")]
+ public double? PP { get; set; }
+
+ #endregion
+
+ public override string ToString() => $"score_id: {ID} user_id: {UserID}";
+
+ ///
+ /// Create a from an API score instance.
+ ///
+ /// A ruleset store, used to populate a ruleset instance in the returned score.
+ /// An optional beatmap, copied into the returned score (for cases where the API does not populate the beatmap).
+ ///
+ public ScoreInfo ToScoreInfo(RulesetStore rulesets, BeatmapInfo? beatmap = null)
+ {
+ var ruleset = rulesets.GetRuleset(RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {RulesetID} not found locally");
+
+ var rulesetInstance = ruleset.CreateInstance();
+
+ var mods = Mods.Select(apiMod => rulesetInstance.CreateModFromAcronym(apiMod.Acronym)).Where(m => m != null).ToArray();
+
+ // all API scores provided by this class are considered to be legacy.
+ mods = mods.Append(rulesetInstance.CreateMod()).ToArray();
+
+ var scoreInfo = ToScoreInfo(mods);
+
+ scoreInfo.Ruleset = ruleset;
+ if (beatmap != null) scoreInfo.BeatmapInfo = beatmap;
+
+ return scoreInfo;
+ }
+
+ ///
+ /// Create a from an API score instance.
+ ///
+ /// The mod instances, resolved from a ruleset.
+ ///
+ public ScoreInfo ToScoreInfo(Mod[] mods) => new ScoreInfo
+ {
+ OnlineID = OnlineID,
+ User = User ?? new APIUser { Id = UserID },
+ BeatmapInfo = new BeatmapInfo { OnlineID = BeatmapID },
+ Ruleset = new RulesetInfo { OnlineID = RulesetID },
+ Passed = Passed,
+ TotalScore = TotalScore,
+ Accuracy = Accuracy,
+ MaxCombo = MaxCombo,
+ Rank = Rank,
+ Statistics = Statistics,
+ Date = EndedAt ?? DateTimeOffset.Now,
+ Hash = "online", // TODO: temporary?
+ HasReplay = HasReplay,
+ Mods = mods,
+ PP = PP,
+ };
+
+ public long OnlineID => ID ?? -1;
+ }
+}
diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs
index c2e54d0d7b..e50fc356eb 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs
@@ -87,7 +87,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
MD5Hash = apiBeatmap.MD5Hash
};
- scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.CreateScoreInfo(rulesets, beatmapInfo)).ToArray(), loadCancellationSource.Token)
+ scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.ToScoreInfo(rulesets, beatmapInfo)).ToArray(), loadCancellationSource.Token)
.ContinueWith(task => Schedule(() =>
{
if (loadCancellationSource.IsCancellationRequested)
@@ -101,7 +101,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
scoreTable.Show();
var userScore = value.UserScore;
- var userScoreInfo = userScore?.Score.CreateScoreInfo(rulesets, beatmapInfo);
+ var userScoreInfo = userScore?.Score.ToScoreInfo(rulesets, beatmapInfo);
topScoresContainer.Add(new DrawableTopScore(topScore));
diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs
index a07cf1608d..493cd66258 100644
--- a/osu.Game/Overlays/DialogOverlay.cs
+++ b/osu.Game/Overlays/DialogOverlay.cs
@@ -49,43 +49,54 @@ namespace osu.Game.Overlays
public void Push(PopupDialog dialog)
{
- if (dialog == CurrentDialog || dialog.State.Value != Visibility.Visible) return;
-
- var lastDialog = CurrentDialog;
+ if (dialog == CurrentDialog || dialog.State.Value == Visibility.Hidden) return;
// Immediately update the externally accessible property as this may be used for checks even before
// a DialogOverlay instance has finished loading.
+ var lastDialog = CurrentDialog;
CurrentDialog = dialog;
- Scheduler.Add(() =>
+ Schedule(() =>
{
// if any existing dialog is being displayed, dismiss it before showing a new one.
lastDialog?.Hide();
- dialog.State.ValueChanged += state => onDialogOnStateChanged(dialog, state.NewValue);
- dialogContainer.Add(dialog);
+ // if the new dialog is hidden before added to the dialogContainer, bypass any further operations.
+ if (dialog.State.Value == Visibility.Hidden)
+ {
+ dismiss();
+ return;
+ }
+
+ dialogContainer.Add(dialog);
Show();
- }, false);
+
+ dialog.State.BindValueChanged(state =>
+ {
+ if (state.NewValue != Visibility.Hidden) return;
+
+ // Trigger the demise of the dialog as soon as it hides.
+ dialog.Delay(PopupDialog.EXIT_DURATION).Expire();
+
+ dismiss();
+ });
+ });
+
+ void dismiss()
+ {
+ if (dialog != CurrentDialog) return;
+
+ // Handle the case where the dialog is the currently displayed dialog.
+ // In this scenario, the overlay itself should also be hidden.
+ Hide();
+ CurrentDialog = null;
+ }
}
public override bool IsPresent => Scheduler.HasPendingTasks || dialogContainer.Children.Count > 0;
protected override bool BlockNonPositionalInput => true;
- private void onDialogOnStateChanged(VisibilityContainer dialog, Visibility v)
- {
- if (v != Visibility.Hidden) return;
-
- // handle the dialog being dismissed.
- dialog.Delay(PopupDialog.EXIT_DURATION).Expire();
-
- if (dialog == CurrentDialog)
- {
- Hide();
- CurrentDialog = null;
- }
- }
-
protected override void PopIn()
{
base.PopIn();
@@ -97,7 +108,8 @@ namespace osu.Game.Overlays
base.PopOut();
lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic);
- if (CurrentDialog?.State.Value == Visibility.Visible)
+ // PopOut gets called initially, but we only want to hide dialog when we have been loaded and are present.
+ if (IsLoaded && CurrentDialog?.State.Value == Visibility.Visible)
CurrentDialog.Hide();
}
diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs
index 20ff8f21c8..cb1e96d2f2 100644
--- a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs
+++ b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs
@@ -1,14 +1,24 @@
// 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.Linq;
using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Configuration;
+using osu.Framework.Extensions;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input.Events;
using osu.Framework.Localisation;
+using osu.Framework.Threading;
+using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
using osu.Game.Localisation;
+using osuTK;
namespace osu.Game.Overlays.FirstRunSetup
{
@@ -20,13 +30,175 @@ namespace osu.Game.Overlays.FirstRunSetup
{
Content.Children = new Drawable[]
{
- new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE))
+ new GridContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ RowDimensions = new[]
+ {
+ // Avoid height changes when changing language.
+ new Dimension(GridSizeMode.AutoSize, minSize: 100),
+ },
+ Content = new[]
+ {
+ new Drawable[]
+ {
+ new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE))
+ {
+ Text = FirstRunSetupOverlayStrings.WelcomeDescription,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y
+ },
+ },
+ }
+ },
+ new LanguageSelectionFlow
{
- Text = FirstRunSetupOverlayStrings.WelcomeDescription,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
- },
+ }
};
}
+
+ private class LanguageSelectionFlow : FillFlowContainer
+ {
+ private Bindable frameworkLocale = null!;
+
+ private ScheduledDelegate? updateSelectedDelegate;
+
+ [BackgroundDependencyLoader]
+ private void load(FrameworkConfigManager frameworkConfig)
+ {
+ Direction = FillDirection.Full;
+ Spacing = new Vector2(5);
+
+ ChildrenEnumerable = Enum.GetValues(typeof(Language))
+ .Cast()
+ .Select(l => new LanguageButton(l)
+ {
+ Action = () => frameworkLocale.Value = l.ToCultureCode()
+ });
+
+ frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale);
+ frameworkLocale.BindValueChanged(locale =>
+ {
+ if (!LanguageExtensions.TryParseCultureCode(locale.NewValue, out var language))
+ language = Language.en;
+
+ // Changing language may cause a short period of blocking the UI thread while the new glyphs are loaded.
+ // Scheduling ensures the button animation plays smoothly after any blocking operation completes.
+ // Note that a delay is required (the alternative would be a double-schedule; delay feels better).
+ updateSelectedDelegate?.Cancel();
+ updateSelectedDelegate = Scheduler.AddDelayed(() => updateSelectedStates(language), 50);
+ }, true);
+ }
+
+ private void updateSelectedStates(Language language)
+ {
+ foreach (var c in Children.OfType())
+ c.Selected = c.Language == language;
+ }
+
+ private class LanguageButton : OsuClickableContainer
+ {
+ public readonly Language Language;
+
+ private Box backgroundBox = null!;
+
+ private OsuSpriteText text = null!;
+
+ [Resolved]
+ private OverlayColourProvider colourProvider { get; set; } = null!;
+
+ private bool selected;
+
+ public bool Selected
+ {
+ get => selected;
+ set
+ {
+ if (selected == value)
+ return;
+
+ selected = value;
+
+ if (IsLoaded)
+ updateState();
+ }
+ }
+
+ public LanguageButton(Language language)
+ {
+ Language = language;
+
+ Size = new Vector2(160, 50);
+ Masking = true;
+ CornerRadius = 10;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ InternalChildren = new Drawable[]
+ {
+ backgroundBox = new Box
+ {
+ Alpha = 0,
+ Colour = colourProvider.Background5,
+ RelativeSizeAxes = Axes.Both,
+ },
+ text = new OsuSpriteText
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Colour = colourProvider.Light1,
+ Text = Language.GetDescription(),
+ }
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ updateState();
+ }
+
+ protected override bool OnHover(HoverEvent e)
+ {
+ if (!selected)
+ updateState();
+ return base.OnHover(e);
+ }
+
+ protected override void OnHoverLost(HoverLostEvent e)
+ {
+ if (!selected)
+ updateState();
+ base.OnHoverLost(e);
+ }
+
+ private void updateState()
+ {
+ if (selected)
+ {
+ const double selected_duration = 1000;
+
+ backgroundBox.FadeTo(1, selected_duration, Easing.OutQuint);
+ backgroundBox.FadeColour(colourProvider.Background2, selected_duration, Easing.OutQuint);
+ text.FadeColour(colourProvider.Content1, selected_duration, Easing.OutQuint);
+ text.ScaleTo(1.2f, selected_duration, Easing.OutQuint);
+ }
+ else
+ {
+ const double duration = 500;
+
+ backgroundBox.FadeTo(IsHovered ? 1 : 0, duration, Easing.OutQuint);
+ backgroundBox.FadeColour(colourProvider.Background5, duration, Easing.OutQuint);
+ text.FadeColour(colourProvider.Light1, duration, Easing.OutQuint);
+ text.ScaleTo(1, duration, Easing.OutQuint);
+ }
+ }
+ }
+ }
}
}
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/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs
index a3a176477e..f38077a9a7 100644
--- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs
+++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs
@@ -9,17 +9,20 @@ using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
+using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
@@ -27,6 +30,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Online;
using osu.Game.Online.Chat;
using osu.Game.Online.Rooms;
+using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapSet;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets;
@@ -38,7 +42,7 @@ using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay
{
- public class DrawableRoomPlaylistItem : OsuRearrangeableListItem
+ public class DrawableRoomPlaylistItem : OsuRearrangeableListItem, IHasContextMenu
{
public const float HEIGHT = 50;
@@ -93,6 +97,9 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved]
private RulesetStore rulesets { get; set; }
+ [Resolved]
+ private BeatmapManager beatmaps { get; set; }
+
[Resolved]
private OsuColour colours { get; set; }
@@ -102,6 +109,15 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; }
+ [Resolved(CanBeNull = true)]
+ private BeatmapSetOverlay beatmapOverlay { get; set; }
+
+ [Resolved(CanBeNull = true)]
+ private CollectionManager collectionManager { get; set; }
+
+ [Resolved(CanBeNull = true)]
+ private ManageCollectionsDialog manageCollectionsDialog { get; set; }
+
protected override bool ShouldBeConsideredForInput(Drawable child) => AllowReordering || AllowDeletion || !AllowSelection || SelectedItem.Value == Model;
public DrawableRoomPlaylistItem(PlaylistItem item)
@@ -433,7 +449,7 @@ namespace osu.Game.Screens.OnlinePlay
}
}
},
- }
+ },
};
}
@@ -470,6 +486,31 @@ namespace osu.Game.Screens.OnlinePlay
return true;
}
+ public MenuItem[] ContextMenuItems
+ {
+ get
+ {
+ List
-
+
@@ -84,7 +84,7 @@
-
+