diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs index cac5b9aa6a..f2c77d6a05 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs @@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModEasy : ModEasyWithExtraLives { - public override LocalisableString Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and extra lives!"; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs index 5c8cd6a5ae..275643ca44 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs @@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModEasy : ModEasyWithExtraLives { - public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and extra lives!"; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs index 281b36e70e..97fe0d0bf2 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs @@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModEasy : ModEasyWithExtraLives { - public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and extra lives!"; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 2def7aeb1c..a94f440a01 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -426,6 +426,31 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("countdown started", () => MultiplayerClient.ServerRoom!.ActiveCountdowns.Any()); } + [Test] + public void TestSettingsRemainsOpenOnRoomUpdate() + { + AddStep("set playlist", () => + { + room.Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + + ClickButtonWhenEnabled(); + + AddUntilStep("wait for room join", () => RoomJoined); + + AddStep("open settings", () => this.ChildrenOfType().Single().Show()); + AddAssert("settings opened", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("trigger room update", () => MultiplayerClient.AddPlaylistItem(MultiplayerClient.ServerRoom!.Playlist[0].Clone())); + AddAssert("settings still open", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + } + private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen { [Resolved(canBeNull: true)] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index 7c73fb8321..77fe96310f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -6,8 +6,12 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; @@ -16,10 +20,12 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { @@ -153,10 +159,40 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } + [Test] + public void TestFreeModSelectionDisable() + { + FooterButtonFreeMods freeMods = null!; + + AddAssert("freestyle enabled", () => songSelect.Freestyle.Value, () => Is.True); + AddStep("click icon in free mods button", () => + { + freeMods = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("mod select not visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("toggle freestyle off", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("freestyle disabled", () => songSelect.Freestyle.Value, () => Is.False); + AddStep("click icon in free mods button", () => + { + InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("mod select visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + } + private partial class TestPlaylistsSongSelect : PlaylistsSongSelect { public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; + public new IBindable Freestyle => base.Freestyle; + public TestPlaylistsSongSelect(Room room) : base(room) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index 25611cf8d5..52905fe5da 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -218,7 +218,7 @@ namespace osu.Game.Tests.Visual.Online } private void waitForLoad() - => AddUntilStep("wait for panels to load", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + => AddUntilStep("wait for panels to load", () => this.ChildrenOfType().First().State.Value, () => Is.EqualTo(Visibility.Hidden)); private void assertVisiblePanelCount(int expectedVisible) where T : UserPanel diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs new file mode 100644 index 0000000000..be2e6eb9bf --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs @@ -0,0 +1,181 @@ +// 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.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.SelectV2; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapMetadataWedge : SongSelectComponentsTestScene + { + private APIBeatmapSet? currentOnlineSet; + + private BeatmapMetadataWedge wedge = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapSetRequest set: + if (set.ID == currentOnlineSet?.OnlineID) + { + set.TriggerSuccess(currentOnlineSet); + return true; + } + + return false; + + default: + return false; + } + }; + + Child = wedge = new BeatmapMetadataWedge + { + State = { Value = Visibility.Visible }, + }; + } + + [Test] + public void TestShowHide() + { + AddStep("all metrics", () => + { + var (working, onlineSet) = createTestBeatmap(); + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + + AddStep("hide wedge", () => wedge.Hide()); + AddStep("show wedge", () => wedge.Show()); + } + + [Test] + public void TestVariousMetrics() + { + AddStep("all metrics", () => + { + var (working, onlineSet) = createTestBeatmap(); + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("null beatmap", () => Beatmap.SetDefault()); + AddStep("no source", () => + { + var (working, onlineSet) = createTestBeatmap(); + + working.Metadata.Source = string.Empty; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no success rate", () => + { + var (working, onlineSet) = createTestBeatmap(); + + onlineSet.Beatmaps.Single().PlayCount = 0; + onlineSet.Beatmaps.Single().PassCount = 0; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no user ratings", () => + { + var (working, onlineSet) = createTestBeatmap(); + + onlineSet.Ratings = Array.Empty(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no fail times", () => + { + var (working, onlineSet) = createTestBeatmap(); + + onlineSet.Beatmaps.Single().FailTimes = null; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no metrics", () => + { + var (working, onlineSet) = createTestBeatmap(); + + onlineSet.Ratings = Array.Empty(); + onlineSet.Beatmaps.Single().FailTimes = null; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("local beatmap", () => + { + var (working, onlineSet) = createTestBeatmap(); + + working.BeatmapInfo.OnlineID = 0; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + } + + [Test] + public void TestTruncation() + { + AddStep("long text", () => + { + var (working, onlineSet) = createTestBeatmap(); + + working.BeatmapInfo.Metadata.Author = new RealmUser { Username = "Verrrrryyyy llooonngggggg author" }; + working.BeatmapInfo.Metadata.Source = "Verrrrryyyy llooonngggggg source"; + working.BeatmapInfo.Metadata.Tags = string.Join(' ', Enumerable.Repeat(working.BeatmapInfo.Metadata.Tags, 3)); + onlineSet.Genre = new BeatmapSetOnlineGenre { Id = 12, Name = "Verrrrryyyy llooonngggggg genre" }; + onlineSet.Language = new BeatmapSetOnlineLanguage { Id = 12, Name = "Verrrrryyyy llooonngggggg language" }; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + } + + private (WorkingBeatmap, APIBeatmapSet) createTestBeatmap() + { + var working = CreateWorkingBeatmap(Ruleset.Value); + var onlineSet = new APIBeatmapSet + { + OnlineID = working.BeatmapSetInfo.OnlineID, + Genre = new BeatmapSetOnlineGenre { Id = 15, Name = "Pop" }, + Language = new BeatmapSetOnlineLanguage { Id = 15, Name = "English" }, + Ratings = Enumerable.Range(0, 11).ToArray(), + Beatmaps = new[] + { + new APIBeatmap + { + OnlineID = working.BeatmapInfo.OnlineID, + PlayCount = 10000, + PassCount = 4567, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, + }, + } + }; + + working.BeatmapSetInfo.DateSubmitted = DateTimeOffset.Now; + working.BeatmapSetInfo.DateRanked = DateTimeOffset.Now; + return (working, onlineSet); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs new file mode 100644 index 0000000000..6a14ddc147 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Visual.SongSelect; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapTitleWedge : SongSelectComponentsTestScene + { + private RulesetStore rulesets = null!; + + private BeatmapTitleWedge titleWedge = null!; + private BeatmapTitleWedge.DifficultyDisplay difficultyDisplay => titleWedge.ChildrenOfType().Single(); + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + this.rulesets = rulesets; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddRange(new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + titleWedge = new BeatmapTitleWedge + { + State = { Value = Visibility.Visible }, + }, + }, + } + }); + + AddSliderStep("change star difficulty", 0, 11.9, 4.18, v => + { + difficultyDisplay.ChildrenOfType().Single().Current.Value = new StarDifficulty(v, 0); + }); + } + + [Test] + public void TestRulesetChange() + { + selectBeatmap(Beatmap.Value.Beatmap); + + AddWaitStep("wait for select", 3); + + foreach (var rulesetInfo in rulesets.AvailableRulesets) + { + var testBeatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(rulesetInfo); + + setRuleset(rulesetInfo); + selectBeatmap(testBeatmap); + } + } + + [Test] + public void TestNullBeatmap() + { + selectBeatmap(null); + AddAssert("check default title", () => titleWedge.DisplayedTitle == Beatmap.Default.BeatmapInfo.Metadata.Title); + AddAssert("check default artist", () => titleWedge.DisplayedArtist == Beatmap.Default.BeatmapInfo.Metadata.Artist); + AddAssert("check empty version", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedVersion.ToString())); + AddAssert("check empty author", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedAuthor.ToString())); + AddAssert("check no statistics", () => difficultyDisplay.ChildrenOfType().All(d => !d.Statistics.Any())); + } + + [Test] + public void TestBPMUpdates() + { + const double bpm = 120; + IBeatmap beatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(new OsuRuleset().RulesetInfo); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / bpm }); + + OsuModDoubleTime doubleTime = null!; + + selectBeatmap(beatmap); + checkDisplayedBPM($"{bpm}"); + + AddStep("select DT", () => SelectedMods.Value = new[] { doubleTime = new OsuModDoubleTime() }); + checkDisplayedBPM($"{bpm * 1.5f}"); + + AddStep("change DT rate", () => doubleTime.SpeedChange.Value = 2); + checkDisplayedBPM($"{bpm * 2}"); + + AddStep("select HT", () => SelectedMods.Value = new[] { new OsuModHalfTime() }); + checkDisplayedBPM($"{bpm * 0.75f}"); + } + + [Test] + public void TestWedgeVisibility() + { + AddStep("hide", () => { titleWedge.Hide(); }); + AddWaitStep("wait for hide", 3); + AddAssert("check visibility", () => titleWedge.Alpha == 0); + AddStep("show", () => { titleWedge.Show(); }); + AddWaitStep("wait for show", 1); + AddAssert("check visibility", () => titleWedge.Alpha > 0); + } + + [TestCase(120, 125, null, "120-125 (mostly 120)")] + [TestCase(120, 120.6, null, "120-121 (mostly 120)")] + [TestCase(120, 120.4, null, "120")] + [TestCase(120, 120.6, "DT", "180-182 (mostly 180)")] + [TestCase(120, 120.4, "DT", "180")] + public void TestVaryingBPM(double commonBpm, double otherBpm, string? mod, string expectedDisplay) + { + IBeatmap beatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(new OsuRuleset().RulesetInfo); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); + beatmap.ControlPointInfo.Add(100, new TimingControlPoint { BeatLength = 60 * 1000 / otherBpm }); + beatmap.ControlPointInfo.Add(200, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); + + if (mod != null) + AddStep($"select {mod}", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateModFromAcronym(mod) }); + + selectBeatmap(beatmap); + checkDisplayedBPM(expectedDisplay); + } + + private void setRuleset(RulesetInfo rulesetInfo) + { + AddStep("set ruleset", () => Ruleset.Value = rulesetInfo); + } + + private void selectBeatmap(IBeatmap? b) + { + AddStep($"select {b?.Metadata.Title ?? "null"} beatmap", () => + { + Beatmap.Value = b == null ? Beatmap.Default : CreateWorkingBeatmap(b); + }); + } + + private void checkDisplayedBPM(string target) + { + AddUntilStep($"displayed bpm is {target}", () => + { + var label = titleWedge.ChildrenOfType().Single(l => l.TooltipText == BeatmapsetsStrings.ShowStatsBpm); + return label.Text == target; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs new file mode 100644 index 0000000000..6bf9469021 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Visual.UserInterface; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapTitleWedgeStatistic : ThemeComparisonTestScene + { + private BeatmapTitleWedge.StatisticPlayCount playCount = null!; + private BeatmapTitleWedge.Statistic statistic2 = null!; + private BeatmapTitleWedge.Statistic statistic3 = null!; + private BeatmapTitleWedge.Statistic statistic4 = null!; + + public TestSceneBeatmapTitleWedgeStatistic() + : base(false) + { + } + + [Test] + public void TestLoading() + { + AddStep("setup", () => CreateThemedContent(OverlayColourScheme.Aquamarine)); + AddStep("set loading", () => this.ChildrenOfType().ForEach(s => s.Text = null)); + AddWaitStep("wait", 3); + AddStep("set values", () => + { + playCount.Value = new BeatmapTitleWedge.StatisticPlayCount.Data(1234, 12); + statistic2.Text = "3,234"; + statistic3.Text = "12:34"; + statistic4.Text = "123"; + }); + + AddStep("set large values", () => + { + playCount.Value = new BeatmapTitleWedge.StatisticPlayCount.Data(134587921, 502); + statistic2.Text = "1,048,576"; + statistic3.Text = "2:50:23"; + statistic4.Text = "1238014"; + }); + } + + protected override Drawable CreateContent() => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Children = new[] + { + playCount = new BeatmapTitleWedge.StatisticPlayCount(true, minSize: 50) + { + Value = new BeatmapTitleWedge.StatisticPlayCount.Data(1234, 12), + }, + statistic2 = new BeatmapTitleWedge.Statistic(OsuIcon.Clock, true, minSize: 30) + { + Text = "3,234", + TooltipText = "Statistic 2", + }, + statistic3 = new BeatmapTitleWedge.Statistic(OsuIcon.Metronome) + { + Text = "12:34", + Margin = new MarginPadding { Right = 10f }, + TooltipText = "Statistic 3", + }, + statistic4 = new BeatmapTitleWedge.Statistic(OsuIcon.Graphics) + { + Text = "123", + TooltipText = "Statistic 4", + }, + }, + }; + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs new file mode 100644 index 0000000000..3dd6fed708 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs @@ -0,0 +1,166 @@ +// 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; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Overlays; +using osu.Game.Screens.SelectV2; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneDifficultyStatisticsDisplay : OsuTestScene + { + private Container displayContainer = null!; + private BeatmapTitleWedge.DifficultyStatisticsDisplay display = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("setup", () => + { + Child = displayContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + display = new BeatmapTitleWedge.DifficultyStatisticsDisplay + { + RelativeSizeAxes = Axes.X, + Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.5f, 0.5f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.7f, 0.7f, 1f), + } + } + } + }; + }); + AddSliderStep("display width", 0, 300, 300, v => + { + if (displayContainer.IsNotNull()) + displayContainer.Width = v; + }); + } + + [Test] + public void TestEmpty() + { + AddStep("set empty", () => display.Statistics = Array.Empty()); + AddAssert("no statistics", () => !display.ChildrenOfType().Any()); + AddAssert("no tiny statistics", () => !display.ChildrenOfType().Single().Content.Any()); + } + + [Test] + public void TestDisplay() + { + AddStep("change data with same labels", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.7f, 0.7f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.4f, 0.8f, 1f), + }); + + AddStep("change data with different labels", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 4", 0.3f, 0.3f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 5", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 6", 0.5f, 0.5f, 1f), + }); + + AddAssert("statistics visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics hidden", () => display.ChildrenOfType().Last().Alpha == 0); + + AddStep("shrink width", () => displayContainer.Width = 100); + AddAssert("statistics hidden", () => display.ChildrenOfType().First().Parent!.Alpha == 0); + AddUntilStep("tiny statistics displayed", () => display.ChildrenOfType().Last().Alpha == 1); + } + + [Test] + public void TestContraction() + { + AddAssert("statistics visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics hidden", () => display.ChildrenOfType().Last().Alpha == 0); + + AddStep("set too many statistics", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.7f, 0.7f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.4f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 4", 0.3f, 0.3f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 5", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 6", 0.5f, 0.5f, 1f), + }); + + AddAssert("statistics hidden", () => display.ChildrenOfType().First().Parent!.Alpha == 0); + AddUntilStep("tiny statistics displayed", () => display.ChildrenOfType().Last().Alpha == 1); + + AddStep("set less statistics", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + }); + + AddAssert("tiny statistics hidden", () => display.ChildrenOfType().Last().Alpha == 0); + AddUntilStep("statistics visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + } + + [Test] + public void TestAutoSize() + { + AddStep("setup auto size", () => Child = display = new BeatmapTitleWedge.DifficultyStatisticsDisplay(true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.5f, 0.5f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.7f, 0.7f, 1f), + } + }); + + AddAssert("statistics visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics hidden", () => display.ChildrenOfType().Last().Alpha == 0); + + AddStep("set too many statistics", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.7f, 0.7f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.4f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 4", 0.3f, 0.3f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 5", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 6", 0.5f, 0.5f, 1f), + }); + + AddAssert("statistics still visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics still hidden", () => display.ChildrenOfType().Last().Alpha == 0); + + AddStep("set less statistics", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + }); + + AddAssert("statistics still visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics still hidden", () => display.ChildrenOfType().Last().Alpha == 0); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs index a91e6e3350..f38fa05218 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; @@ -14,6 +16,9 @@ namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneFPSCounter : OsuTestScene { + [Resolved] + private OsuConfigManager config { get; set; } = null!; + [SetUpSteps] public void SetUpSteps() { @@ -41,6 +46,7 @@ namespace osu.Game.Tests.Visual.UserInterface }, }; }); + AddToggleStep("toggle show", b => config.SetValue(OsuSetting.ShowFpsDisplay, b)); } [Test] diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index 050a78a6b4..93d1f5d5c5 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -23,6 +23,8 @@ namespace osu.Game.Beatmaps.Drawables /// public partial class StarRatingDisplay : CompositeDrawable, IHasCurrentValue { + public const double TRANSFORM_DURATION = 750; + private readonly bool animated; private readonly Box background; private readonly SpriteIcon starIcon; @@ -36,6 +38,12 @@ namespace osu.Game.Beatmaps.Drawables set => current.Current = value; } + /// + /// The difficulty colour currently displayed. + /// Can be used to have other components match the spectrum animation. + /// + public Color4 DisplayedDifficultyColour => background.Colour; + private readonly Bindable displayedStars = new BindableDouble(); /// @@ -139,7 +147,7 @@ namespace osu.Game.Beatmaps.Drawables Current.BindValueChanged(c => { if (animated) - this.TransformBindableTo(displayedStars, c.NewValue.Stars, 750, Easing.OutQuint); + this.TransformBindableTo(displayedStars, c.NewValue.Stars, TRANSFORM_DURATION, Easing.OutQuint); else displayedStars.Value = c.NewValue.Stars; }); @@ -152,8 +160,8 @@ namespace osu.Game.Beatmaps.Drawables background.Colour = colours.ForStarDifficulty(s.NewValue); - starIcon.Colour = s.NewValue >= 6.5 ? colours.Orange1 : colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); - starsText.Colour = s.NewValue >= 6.5 ? colours.Orange1 : colourProvider?.Background5 ?? Color4.Black.Opacity(0.75f); + starIcon.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); + starsText.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4.Black.Opacity(0.75f); }, true); } } diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index dd5e19e167..ff78e93b5e 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -20,6 +20,11 @@ namespace osu.Game.Graphics public static Color4 Gray(float amt) => new Color4(amt, amt, amt, 1f); public static Color4 Gray(byte amt) => new Color4(amt, amt, amt, 255); + /// + /// The maximum star rating colour which can be distinguished against a black background. + /// + public const float STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF = 6.5f; + public static readonly (float, Color4)[] STAR_DIFFICULTY_SPECTRUM = { (0.1f, Color4Extensions.FromHex("aaaaaa")), diff --git a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs index 17e7be1d8b..e64a4c6c07 100644 --- a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs +++ b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs @@ -44,7 +44,8 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.Both, TextAnchor = Anchor.TopRight, Margin = new MarginPadding { Left = 5, Vertical = 10 }, - Text = string.Join('\n', gameHost.Threads.Select(t => t.Name)) + Text = string.Join('\n', gameHost.Threads.Select(t => t.Name)), + ParagraphSpacing = 0, }, textFlow = new OsuTextFlowContainer(cp => { @@ -56,6 +57,7 @@ namespace osu.Game.Graphics.UserInterface Margin = new MarginPadding { Left = 35, Right = 10, Vertical = 10 }, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.TopRight, + ParagraphSpacing = 0, }, }; } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 36712fbdaa..51fadb521a 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -58,6 +58,7 @@ namespace osu.Game.Online.API public IBindable LocalUser => localUser; public IBindableList Friends => friends; + public IBindableList Blocks => blocks; public INotificationsClient NotificationsClient { get; } @@ -66,6 +67,7 @@ namespace osu.Game.Online.API private Bindable localUser { get; } = new Bindable(createGuestUser()); private BindableList friends { get; } = new BindableList(); + private BindableList blocks { get; } = new BindableList(); protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); @@ -638,6 +640,35 @@ namespace osu.Game.Online.API Queue(friendsReq); } + public void UpdateLocalBlocks() + { + if (!IsLoggedIn) + return; + + var blocksReq = new GetBlocksRequest(); + blocksReq.Failure += ex => + { + if (ex is not WebRequestFlushedException) + state.Value = APIState.Failing; + }; + blocksReq.Success += res => + { + var existingBlocks = blocks.Select(f => f.TargetID).ToHashSet(); + var updatedBlocks = res.Select(f => f.TargetID).ToHashSet(); + + // Add new blocked users to local list. + blocks.AddRange(res.Where(r => !existingBlocks.Contains(r.TargetID))); + + // Remove non-blocked users from local list. + blocks.RemoveAll(b => !updatedBlocks.Contains(b.TargetID)); + + // Remove friends who got blocked since last check. + friends.RemoveAll(f => updatedBlocks.Contains(f.TargetID)); + }; + + Queue(blocksReq); + } + private static APIUser createGuestUser() => new GuestUser(); protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index f9649cdd88..0c2ed9903c 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -26,6 +26,7 @@ namespace osu.Game.Online.API }); public BindableList Friends { get; } = new BindableList(); + public BindableList Blocks { get; } = new BindableList(); public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient(); INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient; @@ -180,6 +181,10 @@ namespace osu.Game.Online.API { } + public void UpdateLocalBlocks() + { + } + public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; public IChatClient GetChatClient() => new TestChatClientConnector(this); @@ -194,6 +199,7 @@ namespace osu.Game.Online.API IBindable IAPIProvider.LocalUser => LocalUser; IBindableList IAPIProvider.Friends => Friends; + IBindableList IAPIProvider.Blocks => Blocks; /// /// Skip 2FA requirement for next login. diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 54eaaaafc2..3ab985e41f 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -23,6 +23,11 @@ namespace osu.Game.Online.API /// IBindableList Friends { get; } + /// + /// The users blocked by the local user. + /// + IBindableList Blocks { get; } + /// /// The language supplied by this provider to API requests. /// @@ -118,6 +123,11 @@ namespace osu.Game.Online.API /// void UpdateLocalFriends(); + /// + /// Update the list of users blocked by the current user. + /// + void UpdateLocalBlocks(); + /// /// Schedule a callback to run on the update thread. /// diff --git a/osu.Game/Online/API/Requests/BlockUserRequest.cs b/osu.Game/Online/API/Requests/BlockUserRequest.cs new file mode 100644 index 0000000000..bfcce075eb --- /dev/null +++ b/osu.Game/Online/API/Requests/BlockUserRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class BlockUserRequest : APIRequest + { + public readonly int TargetId; + + public BlockUserRequest(int targetId) + { + TargetId = targetId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.Method = HttpMethod.Post; + req.AddParameter("target", TargetId.ToString(), RequestParameterType.Query); + + return req; + } + + protected override string Target => @"blocks"; + } +} diff --git a/osu.Game/Online/API/Requests/GetBlocksRequest.cs b/osu.Game/Online/API/Requests/GetBlocksRequest.cs new file mode 100644 index 0000000000..c16c256870 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetBlocksRequest.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetBlocksRequest : APIRequest> + { + protected override string Target => @"blocks"; + } +} diff --git a/osu.Game/Online/API/Requests/UnblockUserRequest.cs b/osu.Game/Online/API/Requests/UnblockUserRequest.cs new file mode 100644 index 0000000000..5f88631776 --- /dev/null +++ b/osu.Game/Online/API/Requests/UnblockUserRequest.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class UnblockUserRequest : APIRequest + { + public readonly int TargetId; + + public UnblockUserRequest(int targetId) + { + TargetId = targetId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => @$"blocks/{TargetId}"; + } +} diff --git a/osu.Game/Online/Leaderboards/LeaderboardManager.cs b/osu.Game/Online/Leaderboards/LeaderboardManager.cs index cd77a28893..dd68085103 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardManager.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardManager.cs @@ -5,10 +5,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; @@ -30,8 +30,6 @@ namespace osu.Game.Online.Leaderboards public LeaderboardCriteria? CurrentCriteria { get; private set; } private IDisposable? localScoreSubscription; - private TaskCompletionSource? localFetchCompletionSource; - private TaskCompletionSource? lastFetchCompletionSource; private GetScoresRequest? inFlightOnlineRequest; [Resolved] @@ -43,55 +41,70 @@ namespace osu.Game.Online.Leaderboards [Resolved] private RulesetStore rulesets { get; set; } = null!; - public Task FetchWithCriteriaAsync(LeaderboardCriteria newCriteria) + /// + /// Fetch leaderboard content with the new criteria specified in the background. + /// On completion, will be updated with the results from this call (unless a more recent call with a different criteria has completed). + /// + public void FetchWithCriteria(LeaderboardCriteria newCriteria, bool forceRefresh = false) { - if (CurrentCriteria?.Equals(newCriteria) == true && lastFetchCompletionSource?.Task.IsFaulted == false) - return lastFetchCompletionSource?.Task ?? Task.FromResult(Scores.Value); + if (!forceRefresh && CurrentCriteria?.Equals(newCriteria) == true && scores.Value?.FailState == null) + return; CurrentCriteria = newCriteria; localScoreSubscription?.Dispose(); inFlightOnlineRequest?.Cancel(); - lastFetchCompletionSource?.TrySetCanceled(); scores.Value = null; if (newCriteria.Beatmap == null || newCriteria.Ruleset == null) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoneSelected)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoneSelected); + return; + } switch (newCriteria.Scope) { case BeatmapLeaderboardScope.Local: { - // this task completion source will be marked completed in the `localScoresChanged()` below. - // yes it's twisty, but such are the costs of trying to reconcile data-push / subscription and data-pull / explicit fetch flows. - lastFetchCompletionSource = localFetchCompletionSource = new TaskCompletionSource(); localScoreSubscription = realm.RegisterForNotifications(r => r.All().Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" + $" AND {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + $" AND {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" + $" AND {nameof(ScoreInfo.DeletePending)} == false" , newCriteria.Beatmap.ID, newCriteria.Ruleset.ShortName), localScoresChanged); - return localFetchCompletionSource.Task; + return; } default: { if (!api.IsLoggedIn) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotLoggedIn)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotLoggedIn); + return; + } if (!newCriteria.Ruleset.IsLegacyRuleset()) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.RulesetUnavailable)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.RulesetUnavailable); + return; + } if (newCriteria.Beatmap.OnlineID <= 0 || newCriteria.Beatmap.Status <= BeatmapOnlineStatus.Pending) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.BeatmapUnavailable)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.BeatmapUnavailable); + return; + } if ((newCriteria.Scope.RequiresSupporter(newCriteria.ExactMods != null)) && !api.LocalUser.Value.IsSupporter) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotSupporter)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotSupporter); + return; + } if (newCriteria.Scope == BeatmapLeaderboardScope.Team && api.LocalUser.Value.Team == null) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoTeam)); - - var onlineFetchCompletionSource = new TaskCompletionSource(); - lastFetchCompletionSource = onlineFetchCompletionSource; + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoTeam); + return; + } IReadOnlyList? requestMods = null; @@ -116,12 +129,17 @@ namespace osu.Game.Online.Leaderboards response.UserScore?.CreateScoreInfo(rulesets, newCriteria.Beatmap) ); inFlightOnlineRequest = null; - if (onlineFetchCompletionSource.TrySetResult(result)) - scores.Value = result; + scores.Value = result; }; - newRequest.Failure += _ => onlineFetchCompletionSource.TrySetResult(LeaderboardScores.Failure(LeaderboardFailState.NetworkFailure)); + newRequest.Failure += ex => + { + Logger.Log($@"Failed to fetch leaderboards when displaying results: {ex}", LoggingTarget.Network); + if (ex is not OperationCanceledException) + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NetworkFailure); + }; + api.Queue(inFlightOnlineRequest = newRequest); - return onlineFetchCompletionSource.Task; + break; } } } @@ -157,12 +175,6 @@ namespace osu.Game.Online.Leaderboards newScores = newScores.Detach().OrderByTotalScore(); scores.Value = LeaderboardScores.Success(newScores.ToArray(), null); - - if (localFetchCompletionSource != null && localFetchCompletionSource == lastFetchCompletionSource) - { - localFetchCompletionSource.SetResult(scores.Value); - localFetchCompletionSource = lastFetchCompletionSource = null; - } } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 0c6a06a8fc..cbb2d44a9a 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -801,12 +801,7 @@ namespace osu.Game var newLeaderboard = currentLeaderboard != null ? currentLeaderboard with { Beatmap = databasedBeatmap, Ruleset = databasedScore.ScoreInfo.Ruleset } : new LeaderboardCriteria(databasedBeatmap, databasedScore.ScoreInfo.Ruleset, BeatmapLeaderboardScope.Global, null); - LeaderboardManager.FetchWithCriteriaAsync(newLeaderboard) - .ContinueWith(t => - { - if (t.Exception != null) - Logger.Log($@"Failed to fetch leaderboards when displaying results: {t.Exception}", LoggingTarget.Network); - }); + LeaderboardManager.FetchWithCriteria(newLeaderboard); } switch (presentType) diff --git a/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs b/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs index 957ee23e3b..bdb10a477c 100644 --- a/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs +++ b/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs @@ -18,6 +18,7 @@ namespace osu.Game.Overlays.Mods { public partial class AdjustedAttributesTooltip : VisibilityContainer, ITooltip { + private readonly OverlayColourProvider? colourProvider; private FillFlowContainer attributesFillFlow = null!; private Container content = null!; @@ -27,6 +28,11 @@ namespace osu.Game.Overlays.Mods [Resolved] private OsuColour colours { get; set; } = null!; + public AdjustedAttributesTooltip(OverlayColourProvider? colourProvider = null) + { + this.colourProvider = colourProvider; + } + [BackgroundDependencyLoader] private void load() { @@ -45,7 +51,7 @@ namespace osu.Game.Overlays.Mods new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.Gray3, + Colour = colourProvider?.Background4 ?? colours.Gray3, }, new FillFlowContainer { diff --git a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs index a3dce89ad4..d1be7cecce 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs @@ -44,6 +44,8 @@ namespace osu.Game.Overlays.Profile.Header.Components { AutoSizeAxes = Axes.Both; + OsuTextFlowContainer label; + InternalChildren = new Drawable[] { content = new Container @@ -69,12 +71,9 @@ namespace osu.Game.Overlays.Profile.Header.Components Direction = FillDirection.Horizontal, Children = new Drawable[] { - new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + label = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) { AutoSizeAxes = Axes.Both, - // can't use this because osu-web does weird stuff with \\n. - // Text = UsersStrings.ShowDailyChallengeTitle., - Text = "Daily\nChallenge", Margin = new MarginPadding { Horizontal = 5f, Bottom = 2f }, }, new Container @@ -129,6 +128,10 @@ namespace osu.Game.Overlays.Profile.Header.Components } }, }; + + // can't use this because osu-web does weird stuff with \\n. + // Text = UsersStrings.ShowDailyChallengeTitle., + label.AddParagraph("Daily\nChallenge"); } protected override void LoadComplete() diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index 3605412b2b..ad780cd27d 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -80,6 +80,7 @@ namespace osu.Game.Screens.OnlinePlay Origin = Anchor.Centre, Scale = new Vector2(0.8f), Icon = FontAwesome.Solid.Bars, + Enabled = { BindTarget = Enabled }, Action = () => freeModSelectOverlay.ToggleVisibility() } }); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 6d271a0077..db1b8262b7 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -431,14 +430,25 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer /// private void onRoomUpdated() => Scheduler.AddOnce(() => { - bool newIsRoomJoined = client.Room != null; + bool wasRoomJoined = isRoomJoined; + isRoomJoined = client.Room != null; - if (newIsRoomJoined) + // Creating a room. + if (!wasRoomJoined && !isRoomJoined) + { + roomContent.Hide(); + settingsOverlay.Show(); + } + + // Joining a room. + if (!wasRoomJoined && isRoomJoined) { roomContent.Show(); settingsOverlay.Hide(); } - else if (isRoomJoined) + + // Leaving a room. + if (wasRoomJoined && !isRoomJoined) { Logger.Log($"{this} exiting due to loss of room or connection"); @@ -447,17 +457,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer else ValidForResume = false; } - else - { - Debug.Assert(!isRoomJoined && !newIsRoomJoined); - - // A new room is being created. - // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. - roomContent.Hide(); - settingsOverlay.Show(); - } - - isRoomJoined = newIsRoomJoined; }); /// diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index f5fefa52b5..8197319102 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -62,7 +62,7 @@ namespace osu.Game.Screens.Select.Leaderboards } } - private readonly Bindable fetchedScores = new Bindable(); + private readonly IBindable fetchedScores = new Bindable(); [Resolved] private IBindable ruleset { get; set; } = null!; @@ -82,9 +82,10 @@ namespace osu.Game.Screens.Select.Leaderboards if (filterMods) RefetchScores(); }; - ((IBindable)fetchedScores).BindTo(leaderboardManager.Scores); } + private bool initialFetchComplete; + protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; protected override APIRequest? FetchScores(CancellationToken cancellationToken) @@ -92,30 +93,38 @@ namespace osu.Game.Screens.Select.Leaderboards var fetchBeatmapInfo = BeatmapInfo; var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo?.Ruleset; - leaderboardManager.FetchWithCriteriaAsync(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.ToArray() : null)) - .ContinueWith(t => - { - if (t.Exception != null && !t.IsCanceled) - { - Schedule(() => SetErrorState(LeaderboardState.NetworkFailure)); - return; - } + // Without this check, an initial fetch will be performed and clear global cache. + if (fetchBeatmapInfo == null) + return null; - fetchedScores.UnbindEvents(); - fetchedScores.BindValueChanged(scores => - { - if (scores.NewValue == null) return; + // For now, we forcefully refresh to keep things simple. + // In the future, removing this requirement may be deemed useful, but will need ample testing of edge case scenarios + // (like returning from gameplay after setting a new score, returning to song select after main menu). + leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.ToArray() : null), forceRefresh: true); - if (scores.NewValue.FailState == null) - Schedule(() => SetScores(scores.NewValue.TopScores, scores.NewValue.UserScore)); - else - Schedule(() => SetErrorState((LeaderboardState)scores.NewValue.FailState)); - }, true); - }, cancellationToken); + if (!initialFetchComplete) + { + // only bind this after the first fetch to avoid reading stale scores. + fetchedScores.BindTo(leaderboardManager.Scores); + fetchedScores.BindValueChanged(_ => updateScores(), true); + initialFetchComplete = true; + } return null; } + private void updateScores() + { + var scores = fetchedScores.Value; + + if (scores == null) return; + + if (scores.FailState == null) + Schedule(() => SetScores(scores.TopScores, scores.UserScore)); + else + Schedule(() => SetErrorState((LeaderboardState)scores.FailState)); + } + protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope, Scope != BeatmapLeaderboardScope.Friend) { Action = () => ScoreSelected?.Invoke(model) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs new file mode 100644 index 0000000000..816dfc3f95 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -0,0 +1,357 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge : VisibilityContainer + { + private MetadataDisplay creator = null!; + private MetadataDisplay source = null!; + private MetadataDisplay genre = null!; + private MetadataDisplay language = null!; + private MetadataDisplay tag = null!; + private MetadataDisplay submitted = null!; + private MetadataDisplay ranked = null!; + + private Drawable ratingsWedge = null!; + private SuccessRateDisplay successRateDisplay = null!; + private UserRatingDisplay userRatingDisplay = null!; + private RatingSpreadDisplay ratingSpreadDisplay = null!; + + private Drawable failRetryWedge = null!; + private FailRetryDisplay failRetryDisplay = null!; + + protected override bool StartHidden => true; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IBindable apiState = null!; + + [Resolved] + private ILinkHandler? linkHandler { get; set; } + + [Resolved] + private SongSelect? songSelect { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding { Top = 4f }; + + Width = 0.9f; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Shear = OsuGame.SHEAR, + Children = new[] + { + new ShearAligningWrapper(new Container + { + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 35, Vertical = 16 }, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(), + new Dimension(), + }, + Content = new[] + { + new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + creator = new MetadataDisplay("Creator"), + genre = new MetadataDisplay("Genre"), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + source = new MetadataDisplay("Source"), + language = new MetadataDisplay("Language"), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + submitted = new MetadataDisplay("Submitted"), + ranked = new MetadataDisplay("Ranked"), + }, + }, + }, + }, + }, + tag = new MetadataDisplay("Tags"), + }, + }, + }, + }, + }, + }), + new ShearAligningWrapper(ratingsWedge = new Container + { + Alpha = 0f, + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Content = new[] + { + new[] + { + successRateDisplay = new SuccessRateDisplay(), + Empty(), + userRatingDisplay = new UserRatingDisplay(), + Empty(), + ratingSpreadDisplay = new RatingSpreadDisplay(), + }, + }, + }, + } + }), + new ShearAligningWrapper(failRetryWedge = new Container + { + Alpha = 0f, + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Child = failRetryDisplay = new FailRetryDisplay(), + }, + }, + }), + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + beatmap.BindValueChanged(_ => updateDisplay()); + + apiState = api.State.GetBoundCopy(); + apiState.BindValueChanged(_ => Scheduler.AddOnce(updateDisplay), true); + } + + private const double transition_duration = 300; + + protected override void PopIn() + { + this.FadeIn(transition_duration, Easing.OutQuint) + .MoveToX(0, transition_duration, Easing.OutQuint); + + updateSubWedgeVisibility(); + } + + protected override void PopOut() + { + this.FadeOut(transition_duration, Easing.OutQuint) + .MoveToX(-100, transition_duration, Easing.OutQuint); + + updateSubWedgeVisibility(); + } + + private void updateSubWedgeVisibility() + { + // We could consider hiding individual wedges based on zero data in the future. + // Needs some experimentation on what looks good. + + if (State.Value == Visibility.Visible && currentOnlineBeatmapSet != null) + { + ratingsWedge.FadeIn(transition_duration, Easing.OutQuint) + .MoveToX(0, transition_duration, Easing.OutQuint); + + failRetryWedge.Delay(100) + .FadeIn(transition_duration, Easing.OutQuint) + .MoveToX(0, transition_duration, Easing.OutQuint); + } + else + { + ratingsWedge.FadeOut(transition_duration, Easing.OutQuint) + .MoveToX(-50, transition_duration, Easing.OutQuint); + + failRetryWedge.Delay(100) + .FadeOut(transition_duration, Easing.OutQuint) + .MoveToX(-50, transition_duration, Easing.OutQuint); + } + } + + private void updateDisplay() + { + var metadata = beatmap.Value.Metadata; + var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + + creator.Data = (metadata.Author.Username, () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, metadata.Author))); + + if (!string.IsNullOrEmpty(metadata.Source)) + source.Data = (metadata.Source, () => songSelect?.Search(metadata.Source)); + else + source.Data = ("-", null); + + tag.Tags = (metadata.Tags.Split(' '), t => songSelect?.Search(t)); + submitted.Date = beatmapSetInfo.DateSubmitted; + ranked.Date = beatmapSetInfo.DateRanked; + + if (currentOnlineBeatmapSet == null || currentOnlineBeatmapSet.OnlineID != beatmapSetInfo.OnlineID) + refetchBeatmapSet(); + + updateOnlineDisplay(); + } + + private APIBeatmapSet? currentOnlineBeatmapSet; + private GetBeatmapSetRequest? currentRequest; + + private void refetchBeatmapSet() + { + var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + + currentRequest?.Cancel(); + currentRequest = null; + currentOnlineBeatmapSet = null; + + if (beatmapSetInfo.OnlineID >= 1) + { + // todo: consider introducing a BeatmapSetLookupCache for caching benefits. + currentRequest = new GetBeatmapSetRequest(beatmapSetInfo.OnlineID); + currentRequest.Failure += _ => updateOnlineDisplay(); + currentRequest.Success += s => + { + currentOnlineBeatmapSet = s; + updateOnlineDisplay(); + }; + + api.Queue(currentRequest); + } + } + + private void updateOnlineDisplay() + { + if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting) + { + genre.Data = null; + language.Data = null; + return; + } + + if (currentOnlineBeatmapSet == null) + { + genre.Data = ("-", null); + language.Data = ("-", null); + } + else + { + var beatmapInfo = beatmap.Value.BeatmapInfo; + + var onlineBeatmapSet = currentOnlineBeatmapSet; + var onlineBeatmap = onlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmapInfo.OnlineID); + + genre.Data = (onlineBeatmapSet.Genre.Name, () => songSelect?.Search(onlineBeatmapSet.Genre.Name)); + language.Data = (onlineBeatmapSet.Language.Name, () => songSelect?.Search(onlineBeatmapSet.Language.Name)); + + if (onlineBeatmap != null) + { + userRatingDisplay.Data = onlineBeatmapSet.Ratings; + ratingSpreadDisplay.Data = onlineBeatmapSet.Ratings; + successRateDisplay.Data = (onlineBeatmap.PassCount, onlineBeatmap.PlayCount); + failRetryDisplay.Data = onlineBeatmap.FailTimes ?? new APIFailTimes(); + } + } + + updateSubWedgeVisibility(); + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_FailRetryDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_FailRetryDisplay.cs new file mode 100644 index 0000000000..048ec3c40d --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_FailRetryDisplay.cs @@ -0,0 +1,195 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class FailRetryDisplay : CompositeDrawable + { + private readonly GraphDrawable retriesGraph; + private readonly GraphDrawable failsGraph; + + public APIFailTimes Data + { + set + { + int[] retries = value.Retries ?? Array.Empty(); + int[] fails = value.Fails ?? Array.Empty(); + int[] total = retries.Zip(fails, (r, f) => r + f).ToArray(); + + int maximum = total.DefaultIfEmpty(0).Max(); + + retriesGraph.Data = total.Select(r => maximum == 0 ? 0 : (float)r / maximum).ToArray(); + failsGraph.Data = fails.Select(r => maximum == 0 ? 0 : (float)r / maximum).ToArray(); + } + } + + public FailRetryDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowInfoPointsOfFailure, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Margin = new MarginPadding { Bottom = 4f }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 65f, + Children = new[] + { + retriesGraph = new GraphDrawable { RelativeSizeAxes = Axes.Both, Y = -1f }, + failsGraph = new GraphDrawable { RelativeSizeAxes = Axes.Both }, + }, + }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + retriesGraph.Colour = colours.Orange1; + failsGraph.Colour = colours.DarkOrange2; + } + + private partial class GraphDrawable : Drawable + { + private readonly float[] displayedData = new float[100]; + + private float[] data = new float[100]; + + public float[] Data + { + get => data; + set + { + data = value; + Invalidate(Invalidation.DrawNode); + } + } + + protected override void Update() + { + base.Update(); + + bool changed = false; + + for (int i = 0; i < displayedData.Length; i++) + { + float before = displayedData[i]; + float value = data.ElementAtOrDefault(i); + displayedData[i] = (float)Interpolation.DampContinuously(displayedData[i], value, 40, Time.Elapsed); + changed |= displayedData[i] != before; + } + + if (changed) + Invalidate(Invalidation.DrawNode); + } + + protected override DrawNode CreateDrawNode() => new GraphDrawNode(this); + + // todo: consider integrating this with BarGraph + // this is different from BarGraph since this displays each bar with corner radii applied. + private class GraphDrawNode : DrawNode + { + private readonly GraphDrawable source; + + private Vector2 drawSize; + private float[] displayedData = null!; + + public GraphDrawNode(GraphDrawable source) + : base(source) + { + this.source = source; + } + + public override void ApplyState() + { + base.ApplyState(); + + drawSize = source.DrawSize; + displayedData = source.displayedData; + } + + protected override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + const float spacing_constant = 1.5f; + + float position = 0; + float barWidth = drawSize.X / displayedData.Length / spacing_constant; + + float totalSpacing = drawSize.X - barWidth * displayedData.Length; + float spacing = totalSpacing / (displayedData.Length - 1); + + for (int i = 0; i < displayedData.Length; i++) + { + float barHeight = MathF.Max(drawSize.Y * displayedData[i], barWidth); + + drawBar(renderer, position, barWidth, barHeight); + + position += barWidth + spacing; + } + } + + private void drawBar(IRenderer renderer, float position, float width, float height) + { + float cornerRadius = width / 2f; + + Vector3 scale = DrawInfo.MatrixInverse.ExtractScale(); + float blendRange = (scale.X + scale.Y) / 2; + + RectangleF drawRectangle = new RectangleF(new Vector2(position, drawSize.Y - height), new Vector2(width, height)); + Quad screenSpaceDrawQuad = Quad.FromRectangle(drawRectangle) * DrawInfo.Matrix; + + renderer.PushMaskingInfo(new MaskingInfo + { + ScreenSpaceAABB = screenSpaceDrawQuad.AABB, + MaskingRect = drawRectangle.Normalize(), + ConservativeScreenSpaceQuad = screenSpaceDrawQuad, + ToMaskingSpace = DrawInfo.MatrixInverse, + CornerRadius = cornerRadius, + CornerExponent = 2f, + // We are setting the linear blend range to the approximate size of a _pixel_ here. + // This results in the optimal trade-off between crispness and smoothness of the + // edges of the masked region according to sampling theory. + BlendRange = blendRange, + AlphaExponent = 1, + }); + + renderer.DrawQuad(renderer.WhitePixel, screenSpaceDrawQuad, DrawColourInfo.Colour); + renderer.PopMaskingInfo(); + } + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs new file mode 100644 index 0000000000..897349b9cb --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs @@ -0,0 +1,174 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class MetadataDisplay : FillFlowContainer + { + private readonly OsuSpriteText labelText; + private readonly OsuSpriteText contentText; + private readonly OsuSpriteText contentLinkText; + private readonly OsuHoverContainer contentLink; + private readonly DrawableDate contentDate; + private readonly TagsLine contentTags; + private readonly LoadingSpinner contentLoading; + + private (LocalisableString value, Action? linkAction)? data; + + public (LocalisableString value, Action? linkAction)? Data + { + get => data; + set + { + data = value; + + if (value?.linkAction != null) + setLink(value.Value.value, value.Value.linkAction); + else if (value.HasValue) + setText(value.Value.value); + else + setLoading(); + } + } + + public DateTimeOffset? Date + { + set + { + if (value != null) + setDate(value.Value); + else + setText("-"); + } + } + + public (string[] tags, Action linkAction) Tags + { + set => setTags(value.tags, value.linkAction); + } + + public MetadataDisplay(LocalisableString label) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Padding = new MarginPadding { Right = 10 }; + + InternalChildren = new Drawable[] + { + labelText = new OsuSpriteText + { + Text = label, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Caption1.Size, + Children = new Drawable[] + { + contentText = new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Font = OsuFont.Style.Caption1, + }, + contentLink = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Child = contentLinkText = new TruncatingSpriteText + { + Font = OsuFont.Style.Caption1, + }, + }, + contentDate = new DrawableDate(default, OsuFont.Style.Caption1.Size, false), + contentTags = new TagsLine(), + contentLoading = new LoadingSpinner + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Size = new Vector2(10), + Margin = new MarginPadding { Top = 3f }, + State = { Value = Visibility.Visible }, + } + }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + labelText.Colour = colourProvider.Content1; + contentText.Colour = colourProvider.Content2; + contentLink.IdleColour = colourProvider.Light2; + } + + protected override void Update() + { + base.Update(); + contentLinkText.MaxWidth = ChildSize.X; + } + + private void clear() + { + contentText.Text = string.Empty; + contentLinkText.Text = string.Empty; + contentDate.Hide(); + contentTags.Tags = Array.Empty(); + contentLoading.Hide(); + } + + private void setText(LocalisableString text) + { + clear(); + + contentText.Text = text; + } + + private void setLink(LocalisableString text, Action action) => Schedule(() => + { + clear(); + + contentLinkText.Text = text; + contentLink.Action = action; + }); + + private void setDate(DateTimeOffset date) + { + clear(); + + contentDate.Show(); + contentDate.Date = date; + } + + private void setTags(string[] tags, Action link) + { + clear(); + + contentTags.Tags = tags; + contentTags.Action = link; + } + + private void setLoading() + { + clear(); + + contentLoading.Show(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_RatingSpreadDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_RatingSpreadDisplay.cs new file mode 100644 index 0000000000..ee938ecdd9 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_RatingSpreadDisplay.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class RatingSpreadDisplay : CompositeDrawable + { + private const float min_height = 4f; + private const float max_height = 32f; + + private const int rating_range = 10; + + private readonly GraphBar[] graph; + + public int[] Data + { + set + { + if (!value.Any()) + { + foreach (var bar in graph) + bar.ResizeHeightTo(min_height, 300, Easing.OutQuint); + } + else + { + var usableRange = value.Skip(1).Take(rating_range); // adjust for API returning weird empty data at 0. + int maxRating = usableRange.Max(); + + for (int i = 0; i < graph.Length; i++) + graph[i].ResizeHeightTo(min_height + (max_height - min_height) * (maxRating == 0 ? 0 : usableRange.ElementAt(i) / (float)maxRating), 300, Easing.OutQuint); + } + } + } + + public RatingSpreadDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + graph = Enumerable.Range(0, rating_range).Select(_ => new GraphBar()).ToArray(); + + InternalChildren = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 1f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowStatsRatingSpread, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, max_height) }, + ColumnDimensions = graph.SkipLast(1).Select(_ => new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 1f), + }).SelectMany(d => d).Append(new Dimension()).ToArray(), + Content = new[] + { + graph.SkipLast(1).Select(g => new[] + { + g, + Empty() + }).SelectMany(g => g).Append(graph[^1]).ToArray() + }, + } + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + for (int i = 0; i < 10; i++) + { + var left = Interpolation.ValueAt(i, colours.Blue4, colours.Blue0, 0, 10); + var right = Interpolation.ValueAt(i + 1, colours.Blue4, colours.Blue0, 0, 10); + graph[i].Colour = ColourInfo.GradientHorizontal(left, right); + } + } + + private partial class GraphBar : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + + RelativeSizeAxes = Axes.X; + CornerRadius = 2f; + Masking = true; + + InternalChild = new Box { RelativeSizeAxes = Axes.Both }; + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_SuccessRateDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_SuccessRateDisplay.cs new file mode 100644 index 0000000000..6118547274 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_SuccessRateDisplay.cs @@ -0,0 +1,112 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class SuccessRateDisplay : CompositeDrawable, IHasTooltip + { + private readonly OsuSpriteText valueText; + private readonly Circle backgroundBar; + private readonly Circle valueBar; + + private (int passes, int plays) data; + + public (int passes, int plays) Data + { + get => data; + set + { + data = value; + + float ratio = value.plays == 0 ? 0 : (float)value.passes / value.plays; + + valueText.Text = ratio.ToLocalisableString(@"0.##%"); + valueText.MoveToX(Math.Clamp(ratio, 0.05f, 0.95f), 300, Easing.OutQuint); + valueBar.ResizeWidthTo(ratio, 300, Easing.OutQuint); + } + } + + public LocalisableString TooltipText => $"{data.passes:N0} / {data.plays:N0}"; + + public SuccessRateDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 2f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowInfoSuccessRate, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10f }, + Child = valueText = new OsuSpriteText + { + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.X, + Font = OsuFont.Style.Caption1, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + backgroundBar = new Circle + { + RelativeSizeAxes = Axes.X, + Height = 4f, + }, + valueBar = new Circle + { + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 4f, + }, + }, + } + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + backgroundBar.Colour = colourProvider.Background6; + valueBar.Colour = colours.Lime1; + valueText.Colour = colourProvider.Content2; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs new file mode 100644 index 0000000000..56b83a2578 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs @@ -0,0 +1,223 @@ +// 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.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +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.Layout; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class TagsLine : FillFlowContainer + { + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + private string[] tags = Array.Empty(); + + private TagsOverflowButton? overflowButton; + + public string[] Tags + { + get => tags; + set + { + tags = value; + updateTags(); + } + } + + public Action? Action; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public TagsLine() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Horizontal; + Spacing = new Vector2(4, 0); + + AddLayout(drawSizeLayout); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (!drawSizeLayout.IsValid) + { + updateLayout(); + drawSizeLayout.Validate(); + } + } + + private void updateLayout() + { + if (tags.Length == 0) + return; + + Debug.Assert(overflowButton != null); + + float limit = DrawWidth - overflowButton.DrawWidth - 5; + bool showOverflow = false; + + foreach (var text in Children) + { + if (text.X + text.DrawWidth < limit) + text.Show(); + else + { + showOverflow = true; + text.AlwaysPresent = false; + text.Hide(); + } + } + + if (showOverflow) + overflowButton.Show(); + else + overflowButton.Hide(); + } + + private void updateTags() + { + ChildrenEnumerable = tags.Select(t => new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Action = () => Action?.Invoke(t), + IdleColour = colourProvider.Light2, + AlwaysPresent = true, + Alpha = 0f, + Child = new OsuSpriteText + { + Text = t, + Font = OsuFont.Style.Caption1, + }, + }); + + Add(overflowButton = new TagsOverflowButton(tags) + { + Alpha = 0f, + }); + + drawSizeLayout.Invalidate(); + } + + private partial class TagsOverflowButton : CompositeDrawable, IHasPopover, IHasLineBaseHeight + { + private readonly string[] tags; + + private Box box = null!; + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private SongSelect? songSelect { get; set; } + + public float LineBaseHeight => text.LineBaseHeight; + + public TagsOverflowButton(string[] tags) + { + this.tags = tags; + } + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(OsuFont.Style.Caption1.Size); + CornerRadius = 1.5f; + Masking = true; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = colourProvider.Light1, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Y = -2, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "...", + Colour = colourProvider.Background4, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + box.FadeColour(colourProvider.Content2, 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + box.FadeColour(colourProvider.Light1, 300, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override bool OnClick(ClickEvent e) + { + box.FlashColour(colourProvider.Content1, 300, Easing.OutQuint); + this.ShowPopover(); + return true; + } + + public Popover GetPopover() => new TagsOverflowPopover(tags, songSelect); + } + + public partial class TagsOverflowPopover : OsuPopover + { + private readonly string[] tags; + private readonly SongSelect? songSelect; + + public TagsOverflowPopover(string[] tags, SongSelect? songSelect) + { + this.tags = tags; + this.songSelect = songSelect; + } + + [BackgroundDependencyLoader] + private void load() + { + LinkFlowContainer textFlow; + + Child = textFlow = new LinkFlowContainer(t => t.Font = OsuFont.Style.Caption1) + { + Width = 200, + AutoSizeAxes = Axes.Y, + }; + + foreach (string tag in tags) + { + textFlow.AddLink(tag, () => songSelect?.Search(tag)); + textFlow.AddText(" "); + } + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_UserRatingDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_UserRatingDisplay.cs new file mode 100644 index 0000000000..2f38079577 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_UserRatingDisplay.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class UserRatingDisplay : CompositeDrawable + { + private readonly OsuSpriteText negativeText; + private readonly OsuSpriteText positiveText; + private readonly Circle backgroundBar; + private readonly Circle positiveBar; + + public int[] Data + { + set + { + const int rating_range = 10; + + if (!value.Any()) + { + negativeText.Text = 0.ToLocalisableString(@"N0"); + positiveText.Text = 0.ToLocalisableString(@"N0"); + positiveBar.ResizeWidthTo(0, 300, Easing.OutQuint); + } + else + { + var usableRange = value.Skip(1).Take(rating_range); // adjust for API returning weird empty data at 0. + + int positiveCount = usableRange.Skip(rating_range / 2).Sum(); + int totalCount = usableRange.Sum(); + + negativeText.Text = (totalCount - positiveCount).ToLocalisableString(@"N0"); + positiveText.Text = positiveCount.ToLocalisableString(@"N0"); + positiveBar.ResizeWidthTo(totalCount == 0 ? 0 : (float)positiveCount / totalCount, 300, Easing.OutQuint); + } + } + } + + public UserRatingDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 2f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowStatsUserRating, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10f }, + Children = new[] + { + negativeText = new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Style.Caption1, + }, + positiveText = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.Style.Caption1, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + backgroundBar = new Circle + { + RelativeSizeAxes = Axes.X, + Height = 4f, + }, + positiveBar = new Circle + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 4f, + }, + }, + } + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + backgroundBar.Colour = colours.DarkOrange2; + positiveBar.Colour = colours.Lime1; + negativeText.Colour = colourProvider.Content2; + positiveText.Colour = colourProvider.Content2; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs new file mode 100644 index 0000000000..d892fcb485 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -0,0 +1,327 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Configuration; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge : VisibilityContainer + { + private const float corner_radius = 10; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + protected override bool StartHidden => true; + + private ModSettingChangeTracker? settingChangeTracker; + + private BeatmapSetOnlineStatusPill statusPill = null!; + private Container titleContainer = null!; + private OsuHoverContainer titleLink = null!; + private OsuSpriteText titleLabel = null!; + private Container artistContainer = null!; + private OsuHoverContainer artistLink = null!; + private OsuSpriteText artistLabel = null!; + + internal string DisplayedTitle => titleLabel.Text.ToString(); + internal string DisplayedArtist => artistLabel.Text.ToString(); + + private StatisticPlayCount playCount = null!; + private Statistic favouritesStatistic = null!; + private Statistic lengthStatistic = null!; + private Statistic bpmStatistic = null!; + + [Resolved] + private SongSelect? songSelect { get; set; } + + [Resolved] + private LocalisationManager localisation { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private APIBeatmapSet? currentOnlineBeatmapSet; + private GetBeatmapSetRequest? currentRequest; + + private FillFlowContainer statisticsFlow = null!; + + public BeatmapTitleWedge() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + Shear = OsuGame.SHEAR; + Masking = true; + CornerRadius = corner_radius; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding + { + Top = SongSelect.WEDGE_CONTENT_MARGIN, + Left = SongSelect.WEDGE_CONTENT_MARGIN + }, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + new ShearAligningWrapper(statusPill = new BeatmapSetOnlineStatusPill + { + Shear = -OsuGame.SHEAR, + ShowUnknownStatus = true, + TextSize = OsuFont.Style.Caption1.Size, + TextPadding = new MarginPadding { Horizontal = 6, Vertical = 1 }, + }), + new ShearAligningWrapper(titleContainer = new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Title.Size, + Margin = new MarginPadding { Bottom = -4f }, + Child = titleLink = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Child = titleLabel = new TruncatingSpriteText + { + Shadow = true, + Font = OsuFont.Style.Title, + }, + } + }), + new ShearAligningWrapper(artistContainer = new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Heading2.Size, + Margin = new MarginPadding { Left = 1f }, + Child = artistLink = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Child = artistLabel = new TruncatingSpriteText + { + Shadow = true, + Font = OsuFont.Style.Heading2, + }, + } + }), + new ShearAligningWrapper(statisticsFlow = new FillFlowContainer + { + Shear = -OsuGame.SHEAR, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2f, 0f), + Children = new Drawable[] + { + playCount = new StatisticPlayCount(background: true, leftPadding: SongSelect.WEDGE_CONTENT_MARGIN, minSize: 50f) + { + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + }, + favouritesStatistic = new Statistic(OsuIcon.Heart, background: true, minSize: 25f) + { + TooltipText = BeatmapsStrings.StatusFavourites, + }, + lengthStatistic = new Statistic(OsuIcon.Clock), + bpmStatistic = new Statistic(OsuIcon.Metronome) + { + TooltipText = BeatmapsetsStrings.ShowStatsBpm, + Margin = new MarginPadding { Left = 5f }, + }, + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + Padding = new MarginPadding { Right = -SongSelect.WEDGE_CONTENT_MARGIN }, + Child = new DifficultyDisplay(), + }), + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(_ => updateDisplay()); + ruleset.BindValueChanged(_ => updateDisplay()); + + mods.BindValueChanged(m => + { + settingChangeTracker?.Dispose(); + + updateLengthAndBpmStatistics(); + + settingChangeTracker = new ModSettingChangeTracker(m.NewValue); + settingChangeTracker.SettingChanged += _ => updateLengthAndBpmStatistics(); + }); + + updateDisplay(); + + statisticsFlow.AutoSizeDuration = 100; + statisticsFlow.AutoSizeEasing = Easing.OutQuint; + } + + protected override void PopIn() + { + this.MoveToX(0, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeIn(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void PopOut() + { + this.MoveToX(-150, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void Update() + { + base.Update(); + titleLabel.MaxWidth = titleContainer.DrawWidth - 20; + artistLabel.MaxWidth = artistContainer.DrawWidth - 20; + } + + private void updateDisplay() + { + var metadata = beatmap.Value.Metadata; + var beatmapInfo = beatmap.Value.BeatmapInfo; + var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + + statusPill.Status = beatmapInfo.Status; + + var titleText = new RomanisableString(metadata.TitleUnicode, metadata.Title); + titleLabel.Text = titleText; + titleLink.Action = () => songSelect?.Search(titleText.GetPreferred(localisation.CurrentParameters.Value.PreferOriginalScript)); + + var artistText = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); + artistLabel.Text = artistText; + artistLink.Action = () => songSelect?.Search(artistText.GetPreferred(localisation.CurrentParameters.Value.PreferOriginalScript)); + + updateLengthAndBpmStatistics(); + + if (currentOnlineBeatmapSet == null || currentOnlineBeatmapSet.OnlineID != beatmapSetInfo.OnlineID) + refetchBeatmapSet(); + + updateOnlineDisplay(); + } + + private void updateLengthAndBpmStatistics() + { + var beatmapInfo = beatmap.Value.BeatmapInfo; + + double rate = ModUtils.CalculateRateWithMods(mods.Value); + + int bpmMax = FormatUtils.RoundBPM(beatmap.Value.Beatmap.ControlPointInfo.BPMMaximum, rate); + int bpmMin = FormatUtils.RoundBPM(beatmap.Value.Beatmap.ControlPointInfo.BPMMinimum, rate); + int mostCommonBPM = FormatUtils.RoundBPM(60000 / beatmap.Value.Beatmap.GetMostCommonBeatLength(), rate); + + double drainLength = Math.Round(beatmap.Value.Beatmap.CalculateDrainLength() / rate); + double hitLength = Math.Round(beatmapInfo.Length / rate); + + lengthStatistic.Text = hitLength.ToFormattedDuration(); + lengthStatistic.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()); + + bpmStatistic.Text = bpmMin == bpmMax + ? $"{bpmMin}" + : $"{bpmMin}-{bpmMax} (mostly {mostCommonBPM})"; + } + + private void refetchBeatmapSet() + { + var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + + currentRequest?.Cancel(); + currentRequest = null; + currentOnlineBeatmapSet = null; + + if (beatmapSetInfo.OnlineID >= 1) + { + // todo: consider introducing a BeatmapSetLookupCache for caching benefits. + currentRequest = new GetBeatmapSetRequest(beatmapSetInfo.OnlineID); + currentRequest.Failure += _ => updateOnlineDisplay(); + currentRequest.Success += s => + { + currentOnlineBeatmapSet = s; + updateOnlineDisplay(); + }; + + api.Queue(currentRequest); + } + } + + private void updateOnlineDisplay() + { + if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting) + { + playCount.Value = null; + favouritesStatistic.Text = null; + } + else if (currentOnlineBeatmapSet == null) + { + playCount.Value = new StatisticPlayCount.Data(-1, -1); + favouritesStatistic.Text = "-"; + } + else + { + var onlineBeatmapSet = currentOnlineBeatmapSet; + var onlineBeatmap = currentOnlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmap.Value.BeatmapInfo.OnlineID); + + if (onlineBeatmap != null) + { + playCount.FadeIn(300, Easing.OutQuint); + playCount.Value = new StatisticPlayCount.Data(onlineBeatmap.PlayCount, onlineBeatmap.UserPlayCount); + } + else + { + playCount.FadeOut(300, Easing.OutQuint); + playCount.Value = null; + } + + favouritesStatistic.FadeIn(300, Easing.OutQuint); + favouritesStatistic.Text = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0"); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs new file mode 100644 index 0000000000..7e3589b001 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -0,0 +1,380 @@ +// 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 System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class DifficultyDisplay : CompositeDrawable + { + private const float border_weight = 2; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + private ModSettingChangeTracker? settingChangeTracker; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private StarRatingDisplay starRatingDisplay = null!; + private FillFlowContainer nameLine = null!; + private OsuSpriteText difficultyText = null!; + private OsuSpriteText mappedByText = null!; + private OsuHoverContainer mapperLink = null!; + private OsuSpriteText mapperText = null!; + + internal LocalisableString DisplayedVersion => difficultyText.Text; + internal LocalisableString DisplayedAuthor => mapperText.Text; + + private GridContainer ratingAndNameContainer = null!; + private DifficultyStatisticsDisplay countStatisticsDisplay = null!; + private AdjustableDifficultyStatisticsDisplay difficultyStatisticsDisplay = null!; + + private CancellationTokenSource? cancellationSource; + + public DifficultyDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 10; + Shear = OsuGame.SHEAR; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new ShearAligningWrapper(ratingAndNameContainer = new GridContainer + { + Shear = -OsuGame.SHEAR, + AlwaysPresent = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Vertical = 5f }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 6), + new Dimension(), + }, + Content = new[] + { + new[] + { + starRatingDisplay = new StarRatingDisplay(default, animated: true) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + Empty(), + nameLine = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Bottom = 2f }, + Children = new Drawable[] + { + difficultyText = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + mappedByText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = " mapped by ", + Font = OsuFont.Style.Body, + }, + mapperLink = new MapperLinkContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Child = mapperText = new TruncatingSpriteText + { + Shadow = true, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + }, + }, + }, + } + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Bottom = border_weight, Right = border_weight }, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 10 - border_weight, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5.Opacity(0.8f), + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 20f, Vertical = 7.5f }, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 30), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + countStatisticsDisplay = new DifficultyStatisticsDisplay + { + RelativeSizeAxes = Axes.X, + }, + Empty(), + difficultyStatisticsDisplay = new AdjustableDifficultyStatisticsDisplay(autoSize: true), + } + }, + } + }, + } + }), + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(_ => updateDisplay()); + ruleset.BindValueChanged(_ => updateDisplay()); + + mods.BindValueChanged(m => + { + settingChangeTracker?.Dispose(); + + updateDifficultyStatistics(); + + settingChangeTracker = new ModSettingChangeTracker(m.NewValue); + settingChangeTracker.SettingChanged += _ => updateDifficultyStatistics(); + }); + + updateDisplay(); + } + + [Resolved] + private ILinkHandler? linkHandler { get; set; } + + private void updateDisplay() + { + cancellationSource?.Cancel(); + cancellationSource = new CancellationTokenSource(); + + computeStarDifficulty(cancellationSource.Token); + + if (beatmap.IsDefault) + { + ratingAndNameContainer.FadeOut(300, Easing.OutQuint); + countStatisticsDisplay.Statistics = Array.Empty(); + } + else + { + ratingAndNameContainer.FadeIn(300, Easing.OutQuint); + difficultyText.Text = beatmap.Value.BeatmapInfo.DifficultyName; + mapperLink.Action = () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, beatmap.Value.Metadata.Author)); + mapperText.Text = beatmap.Value.Metadata.Author.Username; + + var playableBeatmap = beatmap.Value.GetPlayableBeatmap(ruleset.Value); + + countStatisticsDisplay.Statistics = playableBeatmap.GetStatistics() + .Select(s => new StatisticDifficulty.Data(s.Name, s.BarDisplayLength ?? 0, s.BarDisplayLength ?? 0, 1, s.Content)) + .ToList(); + } + + updateDifficultyStatistics(); + } + + private void updateDifficultyStatistics() => Scheduler.AddOnce(() => + { + if (beatmap.IsDefault) + { + difficultyStatisticsDisplay.TooltipContent = null; + difficultyStatisticsDisplay.Statistics = Array.Empty(); + return; + } + + BeatmapDifficulty baseDifficulty = beatmap.Value.BeatmapInfo.Difficulty; + BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(baseDifficulty); + + foreach (var mod in mods.Value.OfType()) + mod.ApplyToDifficulty(originalDifficulty); + + var rateAdjustedDifficulty = originalDifficulty; + + if (ruleset.Value != null) + { + double rate = ModUtils.CalculateRateWithMods(mods.Value); + + rateAdjustedDifficulty = ruleset.Value.CreateInstance().GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); + difficultyStatisticsDisplay.TooltipContent = new AdjustedAttributesTooltip.Data(originalDifficulty, rateAdjustedDifficulty); + } + + StatisticDifficulty.Data firstStatistic; + + switch (ruleset.Value?.OnlineID) + { + case 3: + // Account for mania differences locally for now. + // Eventually this should be handled in a more modular way, allowing rulesets to return arbitrary difficulty attributes. + ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + + // For the time being, the key count is static no matter what, because: + // a) The method doesn't have knowledge of the active keymods. Doing so may require considerations for filtering. + // b) Using the difficulty adjustment mod to adjust OD doesn't have an effect on conversion. + int keyCount = legacyRuleset.GetKeyCount(beatmap.Value.BeatmapInfo, mods.Value); + + firstStatistic = new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsCsMania, keyCount, keyCount, 10); + break; + + default: + firstStatistic = new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsCs, baseDifficulty.CircleSize, rateAdjustedDifficulty.CircleSize, 10); + break; + } + + difficultyStatisticsDisplay.Statistics = new[] + { + firstStatistic, + new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsAccuracy, baseDifficulty.OverallDifficulty, rateAdjustedDifficulty.OverallDifficulty, 10), + new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsDrain, baseDifficulty.DrainRate, rateAdjustedDifficulty.DrainRate, 10), + new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsAr, baseDifficulty.ApproachRate, rateAdjustedDifficulty.ApproachRate, 10), + }; + }); + + private void computeStarDifficulty(CancellationToken cancellationToken) + { + difficultyCache.GetDifficultyAsync(beatmap.Value.BeatmapInfo, ruleset.Value, mods.Value, cancellationToken) + .ContinueWith(task => + { + Schedule(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + starRatingDisplay.Current.Value = task.GetResultSafely() ?? default; + }); + }, cancellationToken); + } + + protected override void Update() + { + base.Update(); + + difficultyText.MaxWidth = Math.Max(nameLine.DrawWidth - mappedByText.DrawWidth - mapperText.DrawWidth - 20, 0); + + // Use difficulty colour until it gets too dark to be visible against dark backgrounds. + Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : starRatingDisplay.DisplayedDifficultyColour; + + difficultyText.Colour = col; + mappedByText.Colour = col; + countStatisticsDisplay.AccentColour = col; + difficultyStatisticsDisplay.AccentColour = col; + } + + private partial class MapperLinkContainer : OsuHoverContainer + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) + { + TooltipText = ContextMenuStrings.ViewProfile; + IdleColour = overlayColourProvider?.Light2 ?? colours.Blue; + } + } + + private partial class AdjustableDifficultyStatisticsDisplay : DifficultyStatisticsDisplay, IHasCustomTooltip + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public ITooltip GetCustomTooltip() => new AdjustedAttributesTooltip(colourProvider); + + public AdjustedAttributesTooltip.Data? TooltipContent { get; set; } + + public AdjustableDifficultyStatisticsDisplay(bool autoSize) + : base(autoSize) + { + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs new file mode 100644 index 0000000000..a185448f36 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs @@ -0,0 +1,209 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class DifficultyStatisticsDisplay : CompositeDrawable + { + private readonly bool autoSize; + private readonly FillFlowContainer statisticsFlow; + private readonly GridContainer tinyStatisticsGrid; + + private IReadOnlyList statistics = Array.Empty(); + + public IReadOnlyList Statistics + { + get => statistics; + set + { + statistics = value; + + if (IsLoaded) + { + updateStatistics(); + updateTinyStatistics(); + } + } + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + if (accentColour == value) + return; + + accentColour = value; + + foreach (var statistic in statisticsFlow) + statistic.AccentColour = value; + } + } + + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public DifficultyStatisticsDisplay(bool autoSize = false) + { + this.autoSize = autoSize; + + if (autoSize) + AutoSizeAxes = Axes.Both; + else + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + statisticsFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(8f, 0f), + Direction = FillDirection.Horizontal, + AlwaysPresent = true, + }, + tinyStatisticsGrid = new GridContainer + { + Alpha = 0f, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 8), + new Dimension(GridSizeMode.AutoSize), + } + }, + }; + + AddLayout(drawSizeLayout); + } + + [Resolved] + private LocalisationManager localisations { get; set; } = null!; + + private IBindable? localisationParameters; + + protected override void LoadComplete() + { + base.LoadComplete(); + + localisationParameters = localisations.CurrentParameters.GetBoundCopy(); + localisationParameters.BindValueChanged(_ => updateStatisticsSizing()); + + updateStatistics(); + updateTinyStatistics(); + } + + protected override void Update() + { + base.Update(); + + if (!drawSizeLayout.IsValid) + { + updateLayout(); + drawSizeLayout.Validate(); + } + } + + private bool displayedTinyStatistics; + + private void updateLayout() + { + if (statisticsFlow.Count == 0) + return; + + float flowWidth = statisticsFlow[0].Width * statisticsFlow.Count + statisticsFlow.Spacing.X * (statisticsFlow.Count - 1); + bool tiny = !autoSize && DrawWidth < flowWidth; + + if (displayedTinyStatistics != tiny) + { + if (tiny) + { + statisticsFlow.Hide(); + tinyStatisticsGrid.FadeIn(200, Easing.InQuint); + } + else + { + tinyStatisticsGrid.Hide(); + statisticsFlow.FadeIn(200, Easing.InQuint); + } + + displayedTinyStatistics = tiny; + } + } + + private void updateStatisticsSizing() => SchedulerAfterChildren.AddOnce(() => + { + if (statisticsFlow.Count == 0) + return; + + float statisticWidth = Math.Max(65, statisticsFlow.Max(s => s.LabelWidth)); + + foreach (var statistic in statisticsFlow) + statistic.Width = statisticWidth; + + drawSizeLayout.Invalidate(); + }); + + private void updateStatistics() + { + if (statisticsFlow.Select(s => s.Value.Label) + .SequenceEqual(statistics.Select(s => s.Label))) + { + for (int i = 0; i < statistics.Count; i++) + statisticsFlow[i].Value = statistics[i]; + } + else + { + statisticsFlow.ChildrenEnumerable = statistics.Select(d => new StatisticDifficulty { Value = d }); + updateStatisticsSizing(); + } + } + + private void updateTinyStatistics() + { + tinyStatisticsGrid.RowDimensions = statistics.Select(_ => new Dimension(GridSizeMode.AutoSize)).ToArray(); + tinyStatisticsGrid.Content = statistics.Select(s => new[] + { + new OsuSpriteText + { + Text = s.Label, + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + Colour = colourProvider.Content2, + }, + Empty(), + new OsuSpriteText + { + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + Text = s.Content ?? s.Value.ToLocalisableString("0.##"), + Colour = colourProvider.Content1, + }, + }).ToArray(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs new file mode 100644 index 0000000000..85a0382360 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs @@ -0,0 +1,158 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class Statistic : CompositeDrawable, IHasTooltip + { + private readonly IconUsage icon; + private readonly bool background; + private readonly float leftPadding; + private readonly float? minSize; + + private OsuSpriteText valueText = null!; + private LoadingSpinner loading = null!; + + private LocalisableString? text; + + public LocalisableString? Text + { + get => text; + set + { + text = value; + Scheduler.AddOnce(updateDisplay); + } + } + + public LocalisableString TooltipText { get; set; } + + public Statistic(IconUsage icon, bool background = false, float leftPadding = 10f, float? minSize = null) + { + this.icon = icon; + this.background = background; + this.leftPadding = leftPadding; + this.minSize = minSize; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 5; + Shear = background ? OsuGame.SHEAR : Vector2.Zero; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = background ? 0.2f : 0f, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = background ? leftPadding : 0, Right = background ? 10f : 0f, Vertical = 5f }, + Spacing = new Vector2(4f, 0f), + Shear = background ? -OsuGame.SHEAR : Vector2.Zero, + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = icon, + Size = new Vector2(OsuFont.Style.Heading2.Size), + Colour = colourProvider.Content2, + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.X, + Height = 20, + Children = new Drawable[] + { + loading = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(14f), + State = { Value = Visibility.Visible }, + }, + new GridContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize, minSize: minSize ?? 0), + }, + Content = new[] + { + new[] + { + valueText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Heading2, + Colour = colourProvider.Content2, + Margin = new MarginPadding { Bottom = 2f }, + AlwaysPresent = true, + }, + } + } + }, + }, + }, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Scheduler.AddOnce(updateDisplay); + } + + private void updateDisplay() + { + loading.State.Value = text != null ? Visibility.Hidden : Visibility.Visible; + + if (text != null) + { + valueText.Text = text.Value; + valueText.FadeIn(120, Easing.OutQuint); + } + else + valueText.FadeOut(120, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticDifficulty.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticDifficulty.cs new file mode 100644 index 0000000000..b533d21c1e --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticDifficulty.cs @@ -0,0 +1,196 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class StatisticDifficulty : CompositeDrawable, IHasAccentColour + { + private Data value = new Data(string.Empty, 0, 0, 0); + + public Data Value + { + get => value; + set + { + this.value = value; + + if (IsLoaded) + updateDisplay(); + } + } + + public float LabelWidth => labelText.DrawWidth; + + private readonly Circle bar; + private readonly Circle adjustedBar; + private readonly OsuSpriteText labelText; + private readonly OsuSpriteText valueText; + private readonly SpriteIcon valueIcon; + private readonly Container bars; + + public Color4 AccentColour + { + get => bar.Colour; + set => bar.Colour = value; + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public StatisticDifficulty() + { + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + bars = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + new Circle + { + RelativeSizeAxes = Axes.X, + Height = 2f, + Colour = Color4.Black, + Masking = true, + CornerRadius = 1f, + Depth = float.MaxValue, + }, + bar = new Circle + { + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 2f, + Masking = true, + CornerRadius = 1f, + }, + adjustedBar = new Circle + { + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 2f, + Masking = true, + CornerRadius = 1f, + }, + }, + }, + labelText = new OsuSpriteText + { + Margin = new MarginPadding { Top = 2f }, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + valueText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Body, + }, + valueIcon = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding + { + Top = -4f, + Left = 2, + }, + Size = new Vector2(8), + } + }, + }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + labelText.Colour = colourProvider.Content2; + valueText.Colour = colourProvider.Content1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDisplay(); + } + + private void updateDisplay() + { + bar.ResizeWidthTo(value.Maximum == 0 ? 0 : Math.Clamp(value.Value / value.Maximum, 0, 1), 300, Easing.OutQuint); + adjustedBar.ResizeWidthTo(value.Maximum == 0 ? 0 : Math.Clamp(value.AdjustedValue / value.Maximum, 0, 1), 300, Easing.OutQuint); + + labelText.Text = value.Label; + valueText.Text = value.Content ?? value.AdjustedValue.ToLocalisableString("0.##"); + + if (value.Value == value.AdjustedValue) + { + adjustedBar.FadeColour(Color4.Transparent, 300, Easing.OutQuint); + bar.FadeIn(300, Easing.OutQuint); + + valueText.FadeColour(Color4.White, 300, Easing.OutQuint); + valueIcon.Hide(); + } + else + { + bool difficultyIncrease = value.Value < value.AdjustedValue; + + if (difficultyIncrease) + { + bars.ChangeChildDepth(adjustedBar, 1); + bar.FadeIn(300, Easing.OutQuint); + adjustedBar.FadeColour(ColourInfo.GradientHorizontal(Color4.Black, colours.Red1), 300, Easing.OutQuint); + + valueText.FadeColour(colours.Red1, 300, Easing.OutQuint); + valueIcon.Show(); + valueIcon.Colour = colours.Red1; + valueIcon.Icon = FontAwesome.Solid.SortUp; + } + else + { + bar.FadeTo(0.5f, 300, Easing.OutQuint); + bars.ChangeChildDepth(adjustedBar, -1); + adjustedBar.FadeColour(colours.Lime1, 300, Easing.OutQuint); + + valueText.FadeColour(colours.Lime1, 300, Easing.OutQuint); + valueIcon.Show(); + valueIcon.Colour = colours.Lime1; + valueIcon.Icon = FontAwesome.Solid.SortDown; + } + } + } + + public record Data(LocalisableString Label, float Value, float AdjustedValue, float Maximum, string? Content = null); + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs new file mode 100644 index 0000000000..87f7c30d17 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs @@ -0,0 +1,151 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class StatisticPlayCount : Statistic, IHasCustomTooltip + { + public Data? Value + { + set + { + base.Text = value?.Total < 0 ? "-" : value?.Total.ToLocalisableString("N0"); + TooltipContent = value; + } + } + + public new LocalisableString? Text + { + set => throw new InvalidOperationException($"Use {nameof(Value)} instead."); + } + + public Data? TooltipContent { get; private set; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public StatisticPlayCount(bool background = false, float leftPadding = 10, float? minSize = null) + : base(OsuIcon.Play, background, leftPadding, minSize) + { + } + + ITooltip IHasCustomTooltip.GetCustomTooltip() => new PlayCountTooltip(colourProvider); + + public record Data(int Total, int User); + + private partial class PlayCountTooltip : VisibilityContainer, ITooltip + { + private readonly OverlayColourProvider colourProvider; + + private OsuSpriteText totalPlaysText = null!; + private OsuSpriteText personalPlaysText = null!; + + public PlayCountTooltip(OverlayColourProvider colourProvider) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + CornerRadius = 10; + Masking = true; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Radius = 10f, + }; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding(10), + Direction = FillDirection.Horizontal, + Spacing = new Vector2(16f, 0f), + Children = new[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new[] + { + new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Text = "Total Plays", + }, + totalPlaysText = new OsuSpriteText + { + Colour = colourProvider.Content1, + Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular), + }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new[] + { + new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Text = "Personal Plays", + }, + personalPlaysText = new OsuSpriteText + { + Colour = colourProvider.Content1, + Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular), + }, + } + }, + } + }, + }; + } + + public void SetContent(Data content) + { + totalPlaysText.Text = content.Total < 0 ? "-" : content.Total.ToLocalisableString("N0"); + personalPlaysText.Text = content.User < 0 ? "-" : content.User.ToLocalisableString("N0"); + } + + public void Move(Vector2 pos) => Position = pos; + + protected override void PopIn() => this.FadeIn(300, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(300, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs index c8ae443364..20c27dba92 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -236,7 +236,7 @@ namespace osu.Game.Screens.SelectV2 starRatingDisplay.Current.Value = starDifficulty; starCounter.Current = (float)starDifficulty.Stars; - difficultyIcon.FadeColour(starDifficulty.Stars > 6.5f ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); + difficultyIcon.FadeColour(starDifficulty.Stars > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); var starRatingColour = colours.ForStarDifficulty(starDifficulty.Stars); starCounter.FadeColour(starRatingColour, duration, Easing.OutQuint); diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs index a90a84d115..9a61ce998c 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -270,7 +270,7 @@ namespace osu.Game.Screens.SelectV2 var starDifficulty = starDifficultyBindable?.Value ?? default; AccentColour = colours.ForStarDifficulty(starDifficulty.Stars); - difficultyIcon.FadeColour(starDifficulty.Stars > 6.5f ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); + difficultyIcon.FadeColour(starDifficulty.Stars > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); difficultyStarRating.Current.Value = starDifficulty; } } diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 1010234e1f..fc261163da 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -14,6 +14,7 @@ using osu.Game.Overlays; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Graphics.Containers; @@ -22,8 +23,10 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Resources.Localisation.Web; using osu.Game.Localisation; +using osu.Game.Online.API.Requests; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; +using osu.Game.Overlays.Notifications; using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Users.Drawables; @@ -80,6 +83,9 @@ namespace osu.Game.Users [Resolved] private MetadataClient? metadataClient { get; set; } + [Resolved] + private INotificationOverlay? notifications { get; set; } + [BackgroundDependencyLoader] private void load() { @@ -157,6 +163,10 @@ namespace osu.Game.Users chatOverlay?.Show(); })); + items.Add(isUserBlocked() + ? new OsuMenuItem(UsersStrings.BlocksButtonUnblock, MenuItemType.Standard, () => toggleBlock(false)) + : new OsuMenuItem(UsersStrings.BlocksButtonBlock, MenuItemType.Destructive, () => toggleBlock(true))); + if (isUserOnline()) { items.Add(new OsuMenuItem(ContextMenuStrings.SpectatePlayer, MenuItemType.Standard, () => @@ -179,9 +189,31 @@ namespace osu.Game.Users bool isUserOnline() => metadataClient?.GetPresence(User.OnlineID) != null; bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true; + bool isUserBlocked() => api.Blocks.Any(b => b.TargetID == User.OnlineID); } } + private void toggleBlock(bool block) + { + APIRequest req = block ? new BlockUserRequest(User.OnlineID) : new UnblockUserRequest(User.OnlineID); + + req.Success += () => + { + api.UpdateLocalBlocks(); + }; + + req.Failure += e => + { + notifications?.Post(new SimpleNotification + { + Text = e.Message, + Icon = FontAwesome.Solid.Times, + }); + }; + + api.Queue(req); + } + public IEnumerable FilterTerms => [User.Username]; public bool MatchingFilter