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")}";