diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs
index 1fbdbafec4..bb5499b1a5 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs
@@ -85,9 +85,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration);
Scale = new Vector2(Interpolation.ValueAt(Time.Current, 1, 1.5f, drawableRepeat.HitStateUpdateTime, drawableRepeat.HitStateUpdateTime + animDuration, Easing.Out));
+
+ // When hit, don't animate further. This avoids a scale being applied on a scale and looking very weird.
+ return;
}
- else
- Scale = Vector2.One;
+
+ Scale = Vector2.One;
const float move_distance = -12;
const float scale_amount = 1.3f;
diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj
index b02425eadd..a8fc9536b9 100644
--- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj
+++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj
@@ -28,6 +28,7 @@
+
diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj
index da07373037..9f13b0587b 100644
--- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj
+++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj
@@ -29,6 +29,7 @@
+
diff --git a/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs
new file mode 100644
index 0000000000..4a80c71c3d
--- /dev/null
+++ b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs
@@ -0,0 +1,73 @@
+// 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 Bogus;
+using MessagePack;
+using NUnit.Framework;
+using osu.Game.Online.API;
+using osu.Game.Online.Rooms;
+
+namespace osu.Game.Tests.OnlinePlay
+{
+ [TestFixture]
+ public class MultiplayerPlaylistItemTest
+ {
+ [SetUp]
+ public void Setup()
+ {
+ Randomizer.Seed = new Random(1337);
+ }
+
+ [Test]
+ public void TestCloneMultiplayerPlaylistItem()
+ {
+ var faker = new Faker()
+ .StrictMode(true)
+ .RuleFor(o => o.ID, f => f.Random.Long())
+ .RuleFor(o => o.OwnerID, f => f.Random.Int())
+ .RuleFor(o => o.BeatmapID, f => f.Random.Int())
+ .RuleFor(o => o.BeatmapChecksum, f => f.Random.Hash())
+ .RuleFor(o => o.RulesetID, f => f.Random.Int())
+ .RuleFor(o => o.RequiredMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) }))
+ .RuleFor(o => o.AllowedMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) }))
+ .RuleFor(o => o.Expired, f => f.Random.Bool())
+ .RuleFor(o => o.PlaylistOrder, f => f.Random.UShort())
+ .RuleFor(o => o.PlayedAt, f => f.Date.RecentOffset())
+ .RuleFor(o => o.StarRating, f => f.Random.Double())
+ .RuleFor(o => o.Freestyle, f => f.Random.Bool());
+
+ for (int i = 0; i < 100; i++)
+ {
+ MultiplayerPlaylistItem item = faker.Generate();
+ Assert.That(MessagePackSerializer.SerializeToJson(item.Clone()), Is.EqualTo(MessagePackSerializer.SerializeToJson(item)));
+ }
+ }
+
+ [Test]
+ public void TestConstructFromAPIModel()
+ {
+ var faker = new Faker()
+ .StrictMode(true)
+ .RuleFor(o => o.ID, f => f.Random.Long())
+ .RuleFor(o => o.OwnerID, f => f.Random.Int())
+ .RuleFor(o => o.BeatmapID, f => f.Random.Int())
+ .RuleFor(o => o.BeatmapChecksum, f => f.Random.Hash())
+ .RuleFor(o => o.RulesetID, f => f.Random.Int())
+ .RuleFor(o => o.RequiredMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) }))
+ .RuleFor(o => o.AllowedMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) }))
+ .RuleFor(o => o.Expired, f => f.Random.Bool())
+ .RuleFor(o => o.PlaylistOrder, f => f.Random.UShort())
+ .RuleFor(o => o.PlayedAt, f => f.Date.RecentOffset())
+ .RuleFor(o => o.StarRating, f => f.Random.Double())
+ .RuleFor(o => o.Freestyle, f => f.Random.Bool());
+
+ for (int i = 0; i < 100; i++)
+ {
+ MultiplayerPlaylistItem initialItem = faker.Generate();
+ MultiplayerPlaylistItem copiedItem = new MultiplayerPlaylistItem(new PlaylistItem(initialItem));
+ Assert.That(MessagePackSerializer.SerializeToJson(copiedItem), Is.EqualTo(MessagePackSerializer.SerializeToJson(initialItem)));
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
index aa99b22701..92a10628ff 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
@@ -50,21 +50,17 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("Set short reference score", () =>
{
+ // 50 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows
List hitEvents =
[
- // 10 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows
new HitEvent(30, 1, HitResult.LargeTickHit, new SliderHeadCircle { ClassicSliderBehaviour = true }, null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
];
+ for (int i = 0; i < 49; i++)
+ {
+ hitEvents.Add(new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null));
+ }
+
foreach (var ev in hitEvents)
ev.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs
index e51ea12e83..14e6a67d3a 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.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;
@@ -336,6 +337,61 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("button hidden", () => this.ChildrenOfType().Single().ChangeSettingsButton.Alpha, () => Is.EqualTo(0));
}
+ [Test]
+ public void TestUserModSelectUpdatesWhenNotVisible()
+ {
+ AddStep("add playlist item", () =>
+ {
+ room.Playlist =
+ [
+ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo)
+ {
+ RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
+ AllowedMods = [new APIMod(new OsuModFlashlight())]
+ }
+ ];
+ });
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("wait for join", () => RoomJoined);
+
+ // 1. Open the mod select overlay and enable flashlight
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded));
+ AddStep("click flashlight panel", () =>
+ {
+ ModPanel panel = this.ChildrenOfType().Single(p => p.Mod is OsuModFlashlight);
+ InputManager.MoveMouseTo(panel);
+ InputManager.Click(MouseButton.Left);
+ });
+ AddUntilStep("flashlight mod enabled", () => MultiplayerClient.ClientRoom!.Users[0].Mods.Any());
+
+ // 2. Close the mod select overlay, edit the playlist to disable allowed mods, and then edit it again to re-enable allowed mods.
+
+ AddStep("close mod select overlay", () => this.ChildrenOfType().Single().Hide());
+ AddUntilStep("mod select overlay not present", () => !this.ChildrenOfType().Single().IsPresent);
+ AddStep("disable allowed mods", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(MultiplayerClient.ServerRoom!.Playlist[0])
+ {
+ AllowedMods = []
+ })));
+ // This would normally be done as part of the above operation with an actual server.
+ AddStep("disable user mods", () => MultiplayerClient.ChangeUserMods(API.LocalUser.Value.OnlineID, Array.Empty()));
+ AddUntilStep("flashlight mod disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any());
+ AddStep("re-enable allowed mods", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(MultiplayerClient.ServerRoom!.Playlist[0])
+ {
+ AllowedMods = [new APIMod(new OsuModFlashlight())]
+ })));
+ AddAssert("flashlight mod still disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any());
+
+ // 3. Open the mod select overlay, check that the flashlight mod panel is deactivated.
+
+ ClickButtonWhenEnabled();
+ AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded));
+ AddAssert("flashlight mod still disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any());
+ AddAssert("flashlight mod panel not activated", () => !this.ChildrenOfType().Single(p => p.Mod is OsuModFlashlight).Active.Value);
+ }
+
private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen
{
[Resolved(canBeNull: true)]
diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj
index a1f43505f0..c86f05c257 100644
--- a/osu.Game.Tests/osu.Game.Tests.csproj
+++ b/osu.Game.Tests/osu.Game.Tests.csproj
@@ -1,6 +1,7 @@
+
diff --git a/osu.Game.Tournament/Models/TournamentMatch.cs b/osu.Game.Tournament/Models/TournamentMatch.cs
index 0a700eb4d6..1d91febd1a 100644
--- a/osu.Game.Tournament/Models/TournamentMatch.cs
+++ b/osu.Game.Tournament/Models/TournamentMatch.cs
@@ -19,6 +19,7 @@ namespace osu.Game.Tournament.Models
{
public int ID;
+ [JsonIgnore]
public List Acronyms
{
get
diff --git a/osu.Game/Configuration/SessionAverageHitErrorTracker.cs b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs
index cd21eb6fa8..49f7657f91 100644
--- a/osu.Game/Configuration/SessionAverageHitErrorTracker.cs
+++ b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs
@@ -40,10 +40,10 @@ namespace osu.Game.Configuration
if (newScore.Mods.Any(m => !m.UserPlayable || m is IHasNoTimedInputs))
return;
- if (newScore.HitEvents.Count < 10)
+ if (newScore.HitEvents.Count < 50)
return;
- if (newScore.HitEvents.CalculateAverageHitError() is not double averageError)
+ if (newScore.HitEvents.CalculateMedianHitError() is not double medianError)
return;
// keep a sane maximum number of entries.
@@ -51,7 +51,7 @@ namespace osu.Game.Configuration
averageHitErrorHistory.RemoveAt(0);
double globalOffset = configManager.Get(OsuSetting.AudioOffset);
- averageHitErrorHistory.Add(new DataPoint(averageError, globalOffset));
+ averageHitErrorHistory.Add(new DataPoint(medianError, globalOffset));
}
public void ClearHistory() => averageHitErrorHistory.Clear();
diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs
index db1722af8c..3c02565fa1 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs
@@ -85,6 +85,7 @@ namespace osu.Game.Online.Multiplayer
/// Retrieves the active as determined by the room's current settings.
///
[IgnoreMember]
+ [JsonIgnore]
public MultiplayerPlaylistItem CurrentPlaylistItem => Playlist.Single(item => item.ID == Settings.PlaylistItemId);
///
diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
index 3234e28166..f58a67294e 100644
--- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
+++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs
@@ -62,11 +62,20 @@ namespace osu.Game.Online.Rooms
[Key(11)]
public bool Freestyle { get; set; }
+ ///
+ /// Creates a new .
+ ///
[SerializationConstructor]
public MultiplayerPlaylistItem()
{
}
+ ///
+ /// Creates a new from an API .
+ ///
+ ///
+ /// This will create unique instances of the and arrays but NOT unique instances of the contained s.
+ ///
public MultiplayerPlaylistItem(PlaylistItem item)
{
ID = item.ID;
@@ -82,5 +91,19 @@ namespace osu.Game.Online.Rooms
StarRating = item.Beatmap.StarRating;
Freestyle = item.Freestyle;
}
+
+ ///
+ /// Creates a copy of this .
+ ///
+ ///
+ /// This will create unique instances of the and arrays but NOT unique instances of the contained s.
+ ///
+ public MultiplayerPlaylistItem Clone()
+ {
+ MultiplayerPlaylistItem clone = (MultiplayerPlaylistItem)MemberwiseClone();
+ clone.RequiredMods = RequiredMods.ToArray();
+ clone.AllowedMods = AllowedMods.ToArray();
+ return clone;
+ }
}
}
diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs
index 817b42f503..427f31fc64 100644
--- a/osu.Game/Online/Rooms/PlaylistItem.cs
+++ b/osu.Game/Online/Rooms/PlaylistItem.cs
@@ -96,8 +96,14 @@ namespace osu.Game.Online.Rooms
Beatmap = beatmap;
}
+ ///
+ /// Creates a new from a .
+ ///
+ ///
+ /// This will create unique instances of the and arrays but NOT unique instances of the contained s.
+ ///
public PlaylistItem(MultiplayerPlaylistItem item)
- : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating })
+ : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating, Checksum = item.BeatmapChecksum })
{
ID = item.ID;
OwnerID = item.OwnerID;
diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs
index fed0c3b51b..39fc8b357b 100644
--- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs
+++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs
@@ -55,20 +55,23 @@ namespace osu.Game.Rulesets.Scoring
}
///
- /// Calculates the average hit offset/error for a sequence of s, where negative numbers mean the user hit too early on average.
+ /// Calculates the median hit offset/error for a sequence of s, where negative numbers mean the user hit too early on average.
///
///
/// A non-null value if unstable rate could be calculated,
/// and if unstable rate cannot be calculated due to being empty.
///
- public static double? CalculateAverageHitError(this IEnumerable hitEvents)
+ public static double? CalculateMedianHitError(this IEnumerable hitEvents)
{
- double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).ToArray();
+ double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).OrderBy(x => x).ToArray();
if (timeOffsets.Length == 0)
return null;
- return timeOffsets.Average();
+ int center = timeOffsets.Length / 2;
+
+ // Use average of the 2 central values if length is even
+ return timeOffsets.Length % 2 == 0 ? (timeOffsets[center - 1] + timeOffsets[center]) / 2 : timeOffsets[center];
}
public static bool AffectsUnstableRate(HitEvent e) => AffectsUnstableRate(e.HitObject, e.Result);
diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
index 57e8aff151..c73a36617d 100644
--- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
-using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using osu.Framework.Allocation;
@@ -27,6 +26,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Menu;
using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.OnlinePlay.Multiplayer;
+using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Utils;
using Container = osu.Framework.Graphics.Containers.Container;
@@ -62,11 +62,6 @@ namespace osu.Game.Screens.OnlinePlay.Match
private Sample? sampleStart;
- ///
- /// Any mods applied by/to the local user.
- ///
- protected readonly Bindable> UserMods = new Bindable>(Array.Empty());
-
[Resolved(CanBeNull = true)]
private IOverlayManager? overlayManager { get; set; }
@@ -245,12 +240,7 @@ namespace osu.Game.Screens.OnlinePlay.Match
}
};
- LoadComponent(UserModsSelectOverlay = new RoomModSelectOverlay
- {
- SelectedItem = { BindTarget = SelectedItem },
- SelectedMods = { BindTarget = UserMods },
- IsValidMod = _ => false
- });
+ LoadComponent(UserModsSelectOverlay = new MultiplayerUserModSelectOverlay());
}
protected override void LoadComplete()
@@ -258,7 +248,6 @@ namespace osu.Game.Screens.OnlinePlay.Match
base.LoadComplete();
SelectedItem.BindValueChanged(_ => updateSpecifics());
- UserMods.BindValueChanged(_ => updateSpecifics());
beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateSpecifics());
@@ -441,11 +430,6 @@ namespace osu.Game.Screens.OnlinePlay.Match
? rulesetInstance.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, Room.Type)).ToArray()
: item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
- // Remove any user mods that are no longer allowed.
- Mod[] newUserMods = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray();
- if (!newUserMods.SequenceEqual(UserMods.Value))
- UserMods.Value = newUserMods;
-
// Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info
int beatmapId = GetGameplayBeatmap().OnlineID;
var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", beatmapId);
@@ -456,15 +440,11 @@ namespace osu.Game.Screens.OnlinePlay.Match
Ruleset.Value = GetGameplayRuleset();
if (allowedMods.Length > 0)
- {
UserModsSection.Show();
- UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType());
- }
else
{
UserModsSection.Hide();
UserModsSelectOverlay.Hide();
- UserModsSelectOverlay.IsValidMod = _ => false;
}
if (item.Freestyle)
@@ -488,7 +468,7 @@ namespace osu.Game.Screens.OnlinePlay.Match
UserStyleSection.Hide();
}
- protected virtual APIMod[] GetGameplayMods() => UserMods.Value.Select(m => new APIMod(m)).Concat(SelectedItem.Value!.RequiredMods).ToArray();
+ protected virtual APIMod[] GetGameplayMods() => SelectedItem.Value!.RequiredMods;
protected virtual RulesetInfo GetGameplayRuleset() => Rulesets.GetRuleset(SelectedItem.Value!.RulesetID)!;
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs
new file mode 100644
index 0000000000..8463a4720c
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs
@@ -0,0 +1,124 @@
+// 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 System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.ObjectExtensions;
+using osu.Framework.Threading;
+using osu.Game.Configuration;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Online.Rooms;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Mods;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Utils;
+
+namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
+{
+ public partial class MultiplayerUserModSelectOverlay : UserModSelectOverlay
+ {
+ [Resolved]
+ private MultiplayerClient client { get; set; } = null!;
+
+ [Resolved]
+ private RulesetStore rulesets { get; set; } = null!;
+
+ private ModSettingChangeTracker? modSettingChangeTracker;
+ private ScheduledDelegate? debouncedModSettingsUpdate;
+
+ public MultiplayerUserModSelectOverlay()
+ : base(OverlayColourScheme.Plum)
+ {
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ client.RoomUpdated += onRoomUpdated;
+ SelectedMods.BindValueChanged(onSelectedModsChanged);
+
+ updateValidMods();
+ }
+
+ private void onRoomUpdated()
+ {
+ // Importantly, this is not scheduled because the client must not skip intermediate server states to validate the allowed mods.
+ updateValidMods();
+ }
+
+ private void onSelectedModsChanged(ValueChangedEvent> mods)
+ {
+ modSettingChangeTracker?.Dispose();
+
+ if (client.Room == null)
+ return;
+
+ client.ChangeUserMods(mods.NewValue).FireAndForget();
+
+ modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue);
+ modSettingChangeTracker.SettingChanged += _ =>
+ {
+ // Debounce changes to mod settings so as to not thrash the network.
+ debouncedModSettingsUpdate?.Cancel();
+ debouncedModSettingsUpdate = Scheduler.AddDelayed(() =>
+ {
+ if (client.Room == null)
+ return;
+
+ client.ChangeUserMods(SelectedMods.Value).FireAndForget();
+ }, 500);
+ };
+ }
+
+ private void updateValidMods()
+ {
+ if (client.Room == null || client.LocalUser == null)
+ return;
+
+ MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem;
+ Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance();
+ Mod[] allowedMods = currentItem.Freestyle
+ ? ruleset.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, client.Room.Settings.MatchType)).ToArray()
+ : currentItem.AllowedMods.Select(m => m.ToMod(ruleset)).ToArray();
+
+ // Update the mod panels to reflect the ones which are valid for selection.
+ IsValidMod = allowedMods.Length > 0
+ ? m => allowedMods.Any(a => a.GetType() == m.GetType())
+ : _ => false;
+
+ // Remove any mods that are no longer allowed.
+ Mod[] newUserMods = SelectedMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray();
+ if (!newUserMods.SequenceEqual(SelectedMods.Value))
+ SelectedMods.Value = newUserMods;
+
+ // The active mods include the playlist item's required mods which change separately from the selected mods.
+ IReadOnlyList newActiveMods = ComputeActiveMods();
+ if (!newActiveMods.SequenceEqual(ActiveMods.Value))
+ ActiveMods.Value = newActiveMods;
+ }
+
+ protected override IReadOnlyList ComputeActiveMods()
+ {
+ if (client.Room == null || client.LocalUser == null)
+ return [];
+
+ MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem;
+ Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance();
+ return currentItem.RequiredMods.Select(m => m.ToMod(ruleset)).Concat(base.ComputeActiveMods()).ToArray();
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (client.IsNotNull())
+ client.RoomUpdated -= onRoomUpdated;
+
+ modSettingChangeTracker?.Dispose();
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
index 08a469fa03..0cc033907f 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
@@ -1,7 +1,6 @@
// 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 System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
@@ -11,9 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Screens;
-using osu.Framework.Threading;
using osu.Game.Beatmaps;
-using osu.Game.Configuration;
using osu.Game.Graphics.Cursor;
using osu.Game.Online;
using osu.Game.Online.API;
@@ -23,7 +20,6 @@ using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.Dialog;
using osu.Game.Rulesets;
-using osu.Game.Rulesets.Mods;
using osu.Game.Screens.OnlinePlay.Components;
using osu.Game.Screens.OnlinePlay.Match;
using osu.Game.Screens.OnlinePlay.Match.Components;
@@ -64,7 +60,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
base.LoadComplete();
BeatmapAvailability.BindValueChanged(updateBeatmapAvailability, true);
- UserMods.BindValueChanged(onUserModsChanged);
client.LoadRequested += onLoadRequested;
client.RoomUpdated += onRoomUpdated;
@@ -306,35 +301,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
protected override void PartRoom() => client.LeaveRoom();
- private ModSettingChangeTracker? modSettingChangeTracker;
- private ScheduledDelegate? debouncedModSettingsUpdate;
-
- private void onUserModsChanged(ValueChangedEvent> mods)
- {
- modSettingChangeTracker?.Dispose();
-
- if (client.Room == null)
- return;
-
- client.ChangeUserMods(mods.NewValue).FireAndForget();
-
- modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue);
- modSettingChangeTracker.SettingChanged += onModSettingsChanged;
- }
-
- private void onModSettingsChanged(Mod mod)
- {
- // Debounce changes to mod settings so as to not thrash the network.
- debouncedModSettingsUpdate?.Cancel();
- debouncedModSettingsUpdate = Scheduler.AddDelayed(() =>
- {
- if (client.Room == null)
- return;
-
- client.ChangeUserMods(UserMods.Value).FireAndForget();
- }, 500);
- }
-
private void updateBeatmapAvailability(ValueChangedEvent availability)
{
if (client.Room == null)
@@ -462,8 +428,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
client.RoomUpdated -= onRoomUpdated;
client.LoadRequested -= onLoadRequested;
}
-
- modSettingChangeTracker?.Dispose();
}
public partial class AddItemButton : PurpleRoundedButton
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index a738a40993..612d66a896 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -135,6 +135,8 @@ namespace osu.Game.Screens.Play
public BreakOverlay BreakOverlay;
+ private LetterboxOverlay letterboxOverlay;
+
///
/// Whether the gameplay is currently in a break.
///
@@ -277,6 +279,12 @@ namespace osu.Game.Screens.Play
var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin);
GameplayClockContainer.Add(new GameplayScrollWheelHandling());
+ // needs to exist in frame stable content, but is used by underlay layers so make sure assigned early.
+ breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor)
+ {
+ Breaks = Beatmap.Value.Beatmap.Breaks
+ };
+
// load the skinning hierarchy first.
// this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources.
GameplayClockContainer.Add(rulesetSkinProvider);
@@ -292,7 +300,7 @@ namespace osu.Game.Screens.Play
Children = new[]
{
// underlay and gameplay should have access to the skinning sources.
- createUnderlayComponents(),
+ createUnderlayComponents(Beatmap.Value),
createGameplayComponents(Beatmap.Value)
}
},
@@ -335,10 +343,13 @@ namespace osu.Game.Screens.Play
dependencies.CacheAs(DrawableRuleset.FrameStableClock);
dependencies.CacheAs(DrawableRuleset.FrameStableClock);
+ letterboxOverlay.Clock = DrawableRuleset.FrameStableClock;
+ letterboxOverlay.ProcessCustomClock = false;
+
// add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components.
// also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.)
// we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there.
- failAnimationContainer.Add(createOverlayComponents(Beatmap.Value));
+ failAnimationContainer.Add(createOverlayComponents());
if (!DrawableRuleset.AllowGameplayOverlays)
{
@@ -409,14 +420,22 @@ namespace osu.Game.Screens.Play
protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart);
- private Drawable createUnderlayComponents()
+ private Drawable createUnderlayComponents(WorkingBeatmap working)
{
var container = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
- DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both },
+ DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods)
+ {
+ RelativeSizeAxes = Axes.Both
+ },
+ letterboxOverlay = new LetterboxOverlay
+ {
+ BreakTracker = breakTracker,
+ Alpha = working.Beatmap.LetterboxInBreaks ? 1 : 0,
+ },
new KiaiGameplayFountains(),
},
};
@@ -434,15 +453,12 @@ namespace osu.Game.Screens.Play
ScoreProcessor,
HealthProcessor,
new ComboEffects(ScoreProcessor),
- breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor)
- {
- Breaks = working.Beatmap.Breaks
- }
+ breakTracker,
}),
}
};
- private Drawable createOverlayComponents(IWorkingBeatmap working)
+ private Drawable createOverlayComponents()
{
var container = new Container
{
@@ -450,13 +466,6 @@ namespace osu.Game.Screens.Play
Children = new[]
{
DimmableStoryboard.OverlayLayerContainer.CreateProxy(),
- new LetterboxOverlay
- {
- Clock = DrawableRuleset.FrameStableClock,
- ProcessCustomClock = false,
- BreakTracker = breakTracker,
- Alpha = working.Beatmap.LetterboxInBreaks ? 1 : 0,
- },
HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard)
{
HoldToQuit =
diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs
index cef5884d39..23ccb3311b 100644
--- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs
+++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs
@@ -63,11 +63,11 @@ namespace osu.Game.Screens.Play.PlayerSettings
[Resolved]
private IGameplayClock? gameplayClock { get; set; }
- private double lastPlayAverage;
+ private double lastPlayMedian;
private double lastPlayBeatmapOffset;
private HitEventTimingDistributionGraph? lastPlayGraph;
- private SettingsButton? useAverageButton;
+ private SettingsButton? calibrateFromLastPlayButton;
private IDisposable? beatmapOffsetSubscription;
@@ -196,7 +196,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
var hitEvents = score.NewValue.HitEvents;
- if (!(hitEvents.CalculateAverageHitError() is double average))
+ if (!(hitEvents.CalculateMedianHitError() is double median))
return;
referenceScoreContainer.Children = new Drawable[]
@@ -210,7 +210,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
// affecting unstable rate here is used as a substitute of determining if a hit event represents a *timed* hit event,
// i.e. an user input that the user had to *time to the track*,
// i.e. one that it *makes sense to use* when doing anything with timing and offsets.
- if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 10)
+ if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 50)
{
referenceScoreContainer.AddRange(new Drawable[]
{
@@ -226,7 +226,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
return;
}
- lastPlayAverage = average;
+ lastPlayMedian = median;
lastPlayBeatmapOffset = Current.Value;
LinkFlowContainer globalOffsetText;
@@ -239,7 +239,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
Height = 50,
},
new AverageHitError(hitEvents),
- useAverageButton = new SettingsButton
+ calibrateFromLastPlayButton = new SettingsButton
{
Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay,
Action = () =>
@@ -247,7 +247,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
if (Current.Disabled)
return;
- Current.Value = lastPlayBeatmapOffset - lastPlayAverage;
+ Current.Value = lastPlayBeatmapOffset - lastPlayMedian;
lastAppliedScore.Value = ReferenceScore.Value;
},
},
@@ -281,8 +281,8 @@ namespace osu.Game.Screens.Play.PlayerSettings
bool allow = allowOffsetAdjust;
- if (useAverageButton != null)
- useAverageButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2);
+ if (calibrateFromLastPlayButton != null)
+ calibrateFromLastPlayButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayMedian, adjustmentSinceLastPlay, Current.Precision / 2);
Current.Disabled = !allow;
}
diff --git a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs
index fb7107cc88..29df085c62 100644
--- a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs
+++ b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs
@@ -8,18 +8,18 @@ using osu.Game.Rulesets.Scoring;
namespace osu.Game.Screens.Ranking.Statistics
{
///
- /// Displays the unstable rate statistic for a given play.
+ /// Displays the average hit error statistic for a given play.
///
public partial class AverageHitError : SimpleStatisticItem
{
///
/// Creates and computes an statistic.
///
- /// Sequence of s to calculate the unstable rate based on.
+ /// Sequence of s to calculate the average hit error based on.
public AverageHitError(IEnumerable hitEvents)
: base("Average Hit Error")
{
- Value = hitEvents.CalculateAverageHitError();
+ Value = hitEvents.CalculateMedianHitError();
}
protected override string DisplayValue(double? value) => value == null ? "(not available)" : $"{Math.Abs(value.Value):N2} ms {(value.Value < 0 ? "early" : "late")}";