diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml index ff6d869e72..a8a5d5e64b 100644 --- a/.github/ISSUE_TEMPLATE/bug-issue.yml +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -11,6 +11,10 @@ body: - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0). - And most importantly, search for your issue both in the [issue listing](https://github.com/ppy/osu/issues) and the [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a). If you find that it already exists, respond with a reaction or add any further information that may be helpful. + # ATTENTION LINUX USERS + + If you are having an issue and it is hardware related, **please open a [q&a discussion](https://github.com/ppy/osu/discussions/categories/q-a)** instead of an issue. There's a high chance your issue is due to your system configuration, and not our software. + - type: dropdown attributes: label: Type @@ -38,7 +42,7 @@ body: - type: input attributes: label: Version - description: The version you encountered this bug on. This is shown at the bottom of the main menu and also at the end of the settings screen. + description: The version you encountered this bug on. This is shown at the end of the settings overlay. validations: required: true - type: markdown @@ -46,22 +50,16 @@ body: value: | ## Logs - Attaching log files is required for every reported bug. See instructions below on how to find them. - - **Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead. + Attaching log files is required for **every** issue, regardless of whether you deem them required or not. See instructions below on how to find them. ### Desktop platforms If the game has not yet been closed since you found the bug: - 1. Head on to game settings and click on "Open osu! folder" - 2. Then open the `logs` folder located there + 1. Head on to game settings and click on "Export logs" + 2. Click the notification to locate the file + 3. Drag the generated `.zip` files into the github issue window - The default places to find the logs on desktop platforms are as follows: - - `%AppData%/osu/logs` *on Windows* - - `~/.local/share/osu/logs` *on Linux* - - `~/Library/Application Support/osu/logs` *on macOS* - - If you have selected a custom location for the game files, you can find the `logs` folder there. + ![export logs button](https://github.com/ppy/osu/assets/191335/cbfa5550-b7ed-4c5c-8dd0-8b87cc90ad9b) ### Mobile platforms @@ -69,10 +67,6 @@ body: - *On Android*, navigate to `Android/data/sh.ppy.osulazer/files/logs` using a file browser app. - *On iOS*, connect your device to a PC and copy the `logs` directory from the app's document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer) - --- - - After locating the `logs` folder, select all log files inside and drag them into the "Logs" box below. - - type: textarea attributes: label: Logs diff --git a/osu.Android.props b/osu.Android.props index 6609db3027..e39143ab93 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + \n\n
\nWelcome to the osu! wiki, a project containing a wide range of osu! related information.\n
\n\n
\n
\n\n# Getting started\n\n[Welcome](/wiki/Welcome) • [Installation](/wiki/Installation) • [Registration](/wiki/Registration) • [Help Centre](/wiki/Help_Centre) • [FAQ](/wiki/FAQ)\n\n
\n
\n\n# Game client\n\n[Interface](/wiki/Interface) • [Options](/wiki/Options) • [Visual settings](/wiki/Visual_Settings) • [Shortcut key reference](/wiki/Shortcut_key_reference) • [Configuration file](/wiki/osu!_Program_Files/User_Configuration_File) • [Program files](/wiki/osu!_Program_Files)\n\n[File formats](/wiki/osu!_File_Formats): [.osz](/wiki/osu!_File_Formats/Osz_(file_format)) • [.osk](/wiki/osu!_File_Formats/Osk_(file_format)) • [.osr](/wiki/osu!_File_Formats/Osr_(file_format)) • [.osu](/wiki/osu!_File_Formats/Osu_(file_format)) • [.osb](/wiki/osu!_File_Formats/Osb_(file_format)) • [.db](/wiki/osu!_File_Formats/Db_(file_format))\n\n
\n
\n\n# Gameplay\n\n[Game modes](/wiki/Game_mode): [osu!](/wiki/Game_mode/osu!) • [osu!taiko](/wiki/Game_mode/osu!taiko) • [osu!catch](/wiki/Game_mode/osu!catch) • [osu!mania](/wiki/Game_mode/osu!mania)\n\n[Beatmap](/wiki/Beatmap) • [Hit object](/wiki/Hit_object) • [Mods](/wiki/Game_modifier) • [Score](/wiki/Score) • [Replay](/wiki/Replay) • [Multi](/wiki/Multi)\n\n
\n
\n\n# [Beatmap editor](/wiki/Beatmap_Editor)\n\nSections: [Compose](/wiki/Beatmap_Editor/Compose) • [Design](/wiki/Beatmap_Editor/Design) • [Timing](/wiki/Beatmap_Editor/Timing) • [Song setup](/wiki/Beatmap_Editor/Song_Setup)\n\nComponents: [AiMod](/wiki/Beatmap_Editor/AiMod) • [Beat snap divisor](/wiki/Beatmap_Editor/Beat_Snap_Divisor) • [Distance snap](/wiki/Beatmap_Editor/Distance_Snap) • [Menu](/wiki/Beatmap_Editor/Menu) • [SB load](/wiki/Beatmap_Editor/SB_Load) • [Timelines](/wiki/Beatmap_Editor/Timelines)\n\n[Beatmapping](/wiki/Beatmapping) • [Difficulty](/wiki/Beatmap/Difficulty) • [Mapping techniques](/wiki/Mapping_Techniques) • [Storyboarding](/wiki/Storyboarding)\n\n
\n
\n\n# Beatmap submission and ranking\n\n[Submission](/wiki/Submission) • [Modding](/wiki/Modding) • [Ranking procedure](/wiki/Beatmap_ranking_procedure) • [Mappers' Guild](/wiki/Mappers_Guild) • [Project Loved](/wiki/Project_Loved)\n\n[Ranking criteria](/wiki/Ranking_Criteria): [osu!](/wiki/Ranking_Criteria/osu!) • [osu!taiko](/wiki/Ranking_Criteria/osu!taiko) • [osu!catch](/wiki/Ranking_Criteria/osu!catch) • [osu!mania](/wiki/Ranking_Criteria/osu!mania)\n\n
\n
\n\n# Community\n\n[Tournaments](/wiki/Tournaments) • [Skinning](/wiki/Skinning) • [Projects](/wiki/Projects) • [Guides](/wiki/Guides) • [osu!dev Discord server](/wiki/osu!dev_Discord_server) • [How you can help](/wiki/How_You_Can_Help!) • [Glossary](/wiki/Glossary)\n\n
\n
\n\n# People\n\n[The Team](/wiki/People/The_Team): [Developers](/wiki/People/The_Team/Developers) • [Global Moderation Team](/wiki/People/The_Team/Global_Moderation_Team) • [Support Team](/wiki/People/The_Team/Support_Team) • [Nomination Assessment Team](/wiki/People/The_Team/Nomination_Assessment_Team) • [Beatmap Nominators](/wiki/People/The_Team/Beatmap_Nominators) • [osu! Alumni](/wiki/People/The_Team/osu!_Alumni) • [Project Loved Team](/wiki/People/The_Team/Project_Loved_Team)\n\nOrganisations: [osu! UCI](/wiki/Organisations/osu!_UCI)\n\n[Community Contributors](/wiki/People/Community_Contributors) • [Users with unique titles](/wiki/People/Users_with_unique_titles)\n\n
\n
\n\n# For developers\n\n[API](/wiki/osu!api) • [Bot account](/wiki/Bot_account) • [Brand identity guidelines](/wiki/Brand_identity_guidelines)\n\n
\n
\n\n# About the wiki\n\n[Sitemap](/wiki/Sitemap) • [Contribution guide](/wiki/osu!_wiki_Contribution_Guide) • [Article styling criteria](/wiki/Article_Styling_Criteria) • [News styling criteria](/wiki/News_Styling_Criteria)\n\n
\n
\n"; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index d4b6bc2b91..8909305602 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -69,8 +69,8 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set current path", () => markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/"); - AddStep("set '/wiki/Main_Page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_Page)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_Page"); + AddStep("set '/wiki/Main_page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_page)"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_page"); AddStep("set '../FAQ''", () => markdownContainer.Text = "[FAQ](../FAQ)"); AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/FAQ"); @@ -275,7 +275,7 @@ Phasellus eu nunc nec ligula semper fringilla. Aliquam magna neque, placerat sed AddStep("set content", () => { markdownContainer.Text = @" -This is a paragraph containing `inline code` synatax. +This is a paragraph containing `inline code` syntax. Oh wow I do love the `WikiMarkdownContainer`, it is very cool! This is a line before the fenced code block: diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index 79c7e3a22e..e70d35f74a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -107,12 +107,12 @@ namespace osu.Game.Tests.Visual.Online }; }); - // From https://osu.ppy.sh/api/v2/wiki/en/Main_Page + // From https://osu.ppy.sh/api/v2/wiki/en/Main_page private APIWikiPage responseMainPage => new APIWikiPage { - Title = "Main Page", - Layout = "main_page", - Path = "Main_Page", + Title = "Main page", + Layout = WikiOverlay.INDEX_PATH.ToLowerInvariant(), // custom classes are always lower snake. + Path = WikiOverlay.INDEX_PATH, Locale = "en", Subtitle = null, Markdown = diff --git a/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs b/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs new file mode 100644 index 0000000000..85cde966b1 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs @@ -0,0 +1,131 @@ +// 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.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings.Sections.Audio; +using osu.Game.Scoring; +using osu.Game.Tests.Visual.Ranking; + +namespace osu.Game.Tests.Visual.Settings +{ + public partial class TestSceneAudioOffsetAdjustControl : OsuTestScene + { + [Resolved] + private SessionStatics statics { get; set; } = null!; + + [Cached] + private SessionAverageHitErrorTracker tracker = new SessionAverageHitErrorTracker(); + + private Container content = null!; + protected override Container Content => content; + + private OsuConfigManager localConfig = null!; + private AudioOffsetAdjustControl adjustControl = null!; + + [BackgroundDependencyLoader] + private void load() + { + localConfig = new OsuConfigManager(LocalStorage); + Dependencies.CacheAs(localConfig); + + base.Content.AddRange(new Drawable[] + { + tracker, + content = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 400, + AutoSizeAxes = Axes.Y + } + }); + } + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = adjustControl = new AudioOffsetAdjustControl + { + Current = localConfig.GetBindable(OsuSetting.AudioOffset), + }; + + localConfig.SetValue(OsuSetting.AudioOffset, 0.0); + tracker.ClearHistory(); + }); + + [Test] + public void TestDisplay() + { + AddStep("set new score", () => statics.SetValue(Static.LastLocalUserScore, new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(RNG.NextDouble(-100, 100)), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + })); + AddStep("clear history", () => tracker.ClearHistory()); + } + + [Test] + public void TestBehaviour() + { + AddStep("set score with -20ms", () => setScore(-20)); + AddAssert("suggested global offset is 20ms", () => adjustControl.SuggestedOffset.Value, () => Is.EqualTo(20)); + AddStep("clear history", () => tracker.ClearHistory()); + + AddStep("set score with 40ms", () => setScore(40)); + AddAssert("suggested global offset is -40ms", () => adjustControl.SuggestedOffset.Value, () => Is.EqualTo(-40)); + AddStep("clear history", () => tracker.ClearHistory()); + } + + [Test] + public void TestNonZeroGlobalOffset() + { + AddStep("set global offset to -20ms", () => localConfig.SetValue(OsuSetting.AudioOffset, -20.0)); + AddStep("set score with -20ms", () => setScore(-20)); + AddAssert("suggested global offset is 0ms", () => adjustControl.SuggestedOffset.Value, () => Is.EqualTo(0)); + AddStep("clear history", () => tracker.ClearHistory()); + + AddStep("set global offset to 20ms", () => localConfig.SetValue(OsuSetting.AudioOffset, 20.0)); + AddStep("set score with 40ms", () => setScore(40)); + AddAssert("suggested global offset is -20ms", () => adjustControl.SuggestedOffset.Value, () => Is.EqualTo(-20)); + AddStep("clear history", () => tracker.ClearHistory()); + } + + [Test] + public void TestMultiplePlays() + { + AddStep("set score with -20ms", () => setScore(-20)); + AddStep("set score with -10ms", () => setScore(-10)); + AddAssert("suggested global offset is 15ms", () => adjustControl.SuggestedOffset.Value, () => Is.EqualTo(15)); + AddStep("clear history", () => tracker.ClearHistory()); + + AddStep("set score with -20ms", () => setScore(-20)); + AddStep("set global offset to 30ms", () => localConfig.SetValue(OsuSetting.AudioOffset, 30.0)); + AddStep("set score with 10ms", () => setScore(10)); + AddAssert("suggested global offset is 20ms", () => adjustControl.SuggestedOffset.Value, () => Is.EqualTo(20)); + AddStep("clear history", () => tracker.ClearHistory()); + } + + private void setScore(double averageHitError) + { + statics.SetValue(Static.LastLocalUserScore, new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(averageHitError), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + }); + } + + protected override void Dispose(bool isDisposing) + { + if (localConfig.IsNotNull()) + localConfig.Dispose(); + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index 1c4e89e1a2..57c9770c9a 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -13,7 +13,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays; -using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections.Input; using osu.Game.Rulesets.Taiko; using osuTK.Input; @@ -152,7 +151,7 @@ namespace osu.Game.Tests.Visual.Settings AddStep("click first row with two bindings", () => { multiBindingRow = panel.ChildrenOfType().First(row => row.Defaults.Count() > 1); - InputManager.MoveMouseTo(multiBindingRow); + InputManager.MoveMouseTo(multiBindingRow.ChildrenOfType().First()); InputManager.Click(MouseButton.Left); }); @@ -256,7 +255,7 @@ namespace osu.Game.Tests.Visual.Settings AddStep("click first row with two bindings", () => { multiBindingRow = panel.ChildrenOfType().First(row => row.Defaults.Count() > 1); - InputManager.MoveMouseTo(multiBindingRow); + InputManager.MoveMouseTo(multiBindingRow.ChildrenOfType().First()); InputManager.Click(MouseButton.Left); }); @@ -305,7 +304,6 @@ namespace osu.Game.Tests.Visual.Settings section.ChildrenOfType().Single().TriggerClick(); }); AddStep("move mouse to centre", () => InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("wait for collapsed", () => panel.ChildrenOfType().Single().Expanded.Value, () => Is.False); scrollToAndStartBinding("Left (rim)"); AddStep("attempt to bind M1 to two keys", () => InputManager.Click(MouseButton.Left)); @@ -325,7 +323,6 @@ namespace osu.Game.Tests.Visual.Settings section.ChildrenOfType().Single().TriggerClick(); }); AddStep("move mouse to centre", () => InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("wait for collapsed", () => panel.ChildrenOfType().Single().Expanded.Value, () => Is.False); scrollToAndStartBinding("Left (rim)"); AddStep("attempt to bind M1 to two keys", () => InputManager.Click(MouseButton.Left)); @@ -345,7 +342,6 @@ namespace osu.Game.Tests.Visual.Settings section.ChildrenOfType().Single().TriggerClick(); }); AddStep("move mouse to centre", () => InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("wait for collapsed", () => panel.ChildrenOfType().Single().Expanded.Value, () => Is.False); scrollToAndStartBinding("Left (centre)"); AddStep("clear binding", () => { @@ -377,7 +373,6 @@ namespace osu.Game.Tests.Visual.Settings section.ChildrenOfType().Single().TriggerClick(); }); AddStep("move mouse to centre", () => InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("wait for collapsed", () => panel.ChildrenOfType().Single().Expanded.Value, () => Is.False); scrollToAndStartBinding("Left (centre)"); AddStep("clear binding", () => { diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs index 69e489b247..df0fc8de57 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs @@ -41,6 +41,7 @@ namespace osu.Game.Tests.Visual.Settings public void TestBasic() { AddStep("do nothing", () => { }); + AddToggleStep("toggle visibility", visible => settings.State.Value = visible ? Visibility.Visible : Visibility.Hidden); } [Test] @@ -49,12 +50,12 @@ namespace osu.Game.Tests.Visual.Settings AddStep("reset mouse", () => InputManager.MoveMouseTo(settings)); if (beforeLoad) - AddStep("set filter", () => settings.SectionsContainer.ChildrenOfType().First().Current.Value = "scaling"); + AddStep("set filter", () => settings.SectionsContainer.ChildrenOfType().First().Current.Value = "scaling"); AddUntilStep("wait for items to load", () => settings.SectionsContainer.ChildrenOfType().Any()); if (!beforeLoad) - AddStep("set filter", () => settings.SectionsContainer.ChildrenOfType().First().Current.Value = "scaling"); + AddStep("set filter", () => settings.SectionsContainer.ChildrenOfType().First().Current.Value = "scaling"); AddAssert("ensure all items match filter", () => settings.SectionsContainer .ChildrenOfType().Where(f => f.IsPresent) @@ -76,7 +77,7 @@ namespace osu.Game.Tests.Visual.Settings AddUntilStep("wait for items to load", () => settings.SectionsContainer.ChildrenOfType().Any()); - AddStep("set filter", () => settings.SectionsContainer.ChildrenOfType().First().Current.Value = "scaling"); + AddStep("set filter", () => settings.SectionsContainer.ChildrenOfType().First().Current.Value = "scaling"); } [Test] @@ -94,7 +95,7 @@ namespace osu.Game.Tests.Visual.Settings AddStep("reset mouse", () => InputManager.MoveMouseTo(settings)); AddUntilStep("sections loaded", () => settings.SectionsContainer.Children.Count > 0); - AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); + AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); AddStep("open key binding subpanel", () => { @@ -106,13 +107,13 @@ namespace osu.Game.Tests.Visual.Settings AddUntilStep("binding panel textbox focused", () => settings .ChildrenOfType().FirstOrDefault()? - .ChildrenOfType().FirstOrDefault()?.HasFocus == true); + .ChildrenOfType().FirstOrDefault()?.HasFocus == true); AddStep("Press back", () => settings .ChildrenOfType().FirstOrDefault()? - .ChildrenOfType().FirstOrDefault()?.TriggerClick()); + .ChildrenOfType().FirstOrDefault()?.TriggerClick()); - AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); + AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); } [Test] @@ -121,7 +122,7 @@ namespace osu.Game.Tests.Visual.Settings AddStep("reset mouse", () => InputManager.MoveMouseTo(settings)); AddUntilStep("sections loaded", () => settings.SectionsContainer.Children.Count > 0); - AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); + AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); AddStep("open key binding subpanel", () => { @@ -133,19 +134,19 @@ namespace osu.Game.Tests.Visual.Settings AddUntilStep("binding panel textbox focused", () => settings .ChildrenOfType().FirstOrDefault()? - .ChildrenOfType().FirstOrDefault()?.HasFocus == true); + .ChildrenOfType().FirstOrDefault()?.HasFocus == true); AddStep("Escape", () => InputManager.Key(Key.Escape)); - AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); + AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); } [Test] public void TestSearchTextBoxSelectedOnShow() { - SearchTextBox searchTextBox = null!; + SettingsSearchTextBox searchTextBox = null!; - AddStep("set text", () => (searchTextBox = settings.SectionsContainer.ChildrenOfType().First()).Current.Value = "some text"); + AddStep("set text", () => (searchTextBox = settings.SectionsContainer.ChildrenOfType().First()).Current.Value = "some text"); AddAssert("no text selected", () => searchTextBox.SelectedText == string.Empty); AddRepeatStep("toggle visibility", () => settings.ToggleVisibility(), 2); AddAssert("search text selected", () => searchTextBox.SelectedText == searchTextBox.Current.Value); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs index 8650119dd4..4bb2b557ff 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs @@ -14,7 +14,9 @@ using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Select.Details; using osuTK.Graphics; @@ -38,6 +40,12 @@ namespace osu.Game.Tests.Visual.SongSelect Width = 500 }); + [SetUpSteps] + public void SetUpSteps() + { + AddStep("reset game ruleset", () => Ruleset.Value = new OsuRuleset().RulesetInfo); + } + private BeatmapInfo exampleBeatmapInfo => new BeatmapInfo { Ruleset = rulesets.AvailableRulesets.First(), @@ -66,8 +74,10 @@ namespace osu.Game.Tests.Visual.SongSelect } [Test] - public void TestManiaFirstBarText() + public void TestManiaFirstBarTextManiaBeatmap() { + AddStep("set game ruleset to mania", () => Ruleset.Value = new ManiaRuleset().RulesetInfo); + AddStep("set beatmap", () => advancedStats.BeatmapInfo = new BeatmapInfo { Ruleset = rulesets.GetRuleset(3) ?? throw new InvalidOperationException("osu!mania ruleset not found"), @@ -84,6 +94,27 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("first bar text is correct", () => advancedStats.ChildrenOfType().First().Text == BeatmapsetsStrings.ShowStatsCsMania); } + [Test] + public void TestManiaFirstBarTextConvert() + { + AddStep("set game ruleset to mania", () => Ruleset.Value = new ManiaRuleset().RulesetInfo); + + AddStep("set beatmap", () => advancedStats.BeatmapInfo = new BeatmapInfo + { + Ruleset = new OsuRuleset().RulesetInfo, + Difficulty = new BeatmapDifficulty + { + CircleSize = 5, + DrainRate = 4.3f, + OverallDifficulty = 4.5f, + ApproachRate = 3.1f + }, + StarRating = 8 + }); + + AddAssert("first bar text is correct", () => advancedStats.ChildrenOfType().First().Text == BeatmapsetsStrings.ShowStatsCsMania); + } + [Test] public void TestEasyMod() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index c509d40e07..aa4c879468 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -454,6 +454,23 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); } + [Test] + public void TestRewind() + { + const int local_set_count = 3; + const int random_select_count = local_set_count * 3; + loadBeatmaps(setCount: local_set_count); + + for (int i = 0; i < random_select_count; i++) + nextRandom(); + + for (int i = 0; i < random_select_count; i++) + { + prevRandom(); + AddAssert("correct random last selected", () => selectedSets.Peek(), () => Is.EqualTo(carousel.SelectedBeatmapSet)); + } + } + [Test] public void TestRewindToDeletedBeatmap() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index 7cd4f06bce..fd102da026 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; @@ -14,6 +15,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Extensions; using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; @@ -194,6 +196,36 @@ namespace osu.Game.Tests.Visual.SongSelect }); } + [TestCase] + public void TestLengthUpdates() + { + IBeatmap beatmap = createTestBeatmap(new OsuRuleset().RulesetInfo); + double drain = beatmap.CalculateDrainLength(); + beatmap.BeatmapInfo.Length = drain; + + OsuModDoubleTime doubleTime = null; + + selectBeatmap(beatmap); + checkDisplayedLength(drain); + + AddStep("select DT", () => SelectedMods.Value = new[] { doubleTime = new OsuModDoubleTime() }); + checkDisplayedLength(Math.Round(drain / 1.5f)); + + AddStep("change DT rate", () => doubleTime.SpeedChange.Value = 2); + checkDisplayedLength(Math.Round(drain / 2)); + } + + private void checkDisplayedLength(double drain) + { + var displayedLength = drain.ToFormattedDuration(); + + AddUntilStep($"check map drain ({displayedLength})", () => + { + var label = infoWedge.DisplayedContent.ChildrenOfType().Single(l => l.Statistic.Name == BeatmapsetsStrings.ShowStatsTotalLength(displayedLength)); + return label.Statistic.Content == displayedLength.ToString(); + }); + } + private void setRuleset(RulesetInfo rulesetInfo) { Container containerBefore = null; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs index 00a0d4a849..94c6130f15 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs @@ -192,7 +192,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select collection", () => { - InputManager.MoveMouseTo(getCollectionDropdownItems().ElementAt(1)); + InputManager.MoveMouseTo(getCollectionDropdownItemAt(1)); InputManager.Click(MouseButton.Left); }); @@ -206,7 +206,8 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("click manage collections filter", () => { - InputManager.MoveMouseTo(getCollectionDropdownItems().Last()); + int lastItemIndex = control.ChildrenOfType().Single().Items.Count() - 1; + InputManager.MoveMouseTo(getCollectionDropdownItemAt(lastItemIndex)); InputManager.Click(MouseButton.Left); }); @@ -232,10 +233,10 @@ namespace osu.Game.Tests.Visual.SongSelect private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) => AddUntilStep($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'", // A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872 - () => shouldContain == (getCollectionDropdownItems().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName))); + () => shouldContain == control.ChildrenOfType().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName)); private IconButton getAddOrRemoveButton(int index) - => getCollectionDropdownItems().ElementAt(index).ChildrenOfType().Single(); + => getCollectionDropdownItemAt(index).ChildrenOfType().Single(); private void addExpandHeaderStep() => AddStep("expand header", () => { @@ -249,7 +250,11 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Click(MouseButton.Left); }); - private IEnumerable.DropdownMenu.DrawableDropdownMenuItem> getCollectionDropdownItems() - => control.ChildrenOfType().Single().ChildrenOfType.DropdownMenu.DrawableDropdownMenuItem>(); + private Menu.DrawableMenuItem getCollectionDropdownItemAt(int index) + { + // todo: we should be able to use Items, but apparently that's not guaranteed to be ordered... see: https://github.com/ppy/osu-framework/pull/6079 + CollectionFilterMenuItem item = control.ChildrenOfType().Single().ItemSource.ElementAt(index); + return control.ChildrenOfType().Single(i => i.Item.Text.Value == item.CollectionName); + } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 84750d4c16..ce241f3676 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -22,7 +22,6 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Extensions; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Overlays; @@ -421,6 +420,7 @@ namespace osu.Game.Tests.Visual.SongSelect } [Test] + [Ignore("temporary while peppy investigates. probably realm batching related.")] public void TestSelectionRetainedOnBeatmapUpdate() { createSongSelect(); @@ -465,7 +465,7 @@ namespace osu.Game.Tests.Visual.SongSelect manager.Import(testBeatmapSetInfo); }, 10); - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID, () => Is.EqualTo(originalOnlineSetID)); Task?> updateTask = null!; @@ -477,7 +477,7 @@ namespace osu.Game.Tests.Visual.SongSelect }); AddUntilStep("wait for update completion", () => updateTask.IsCompleted); - AddUntilStep("retained selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); + AddUntilStep("retained selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID, () => Is.EqualTo(originalOnlineSetID)); } [Test] @@ -614,7 +614,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); @@ -648,7 +648,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - AddStep("reset filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = string.Empty); + AddStep("reset filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = string.Empty); AddAssert("game still correct", () => Beatmap.Value?.BeatmapInfo.MatchesOnlineID(target) == true); AddAssert("carousel still correct", () => songSelect!.Carousel.SelectedBeatmapInfo.MatchesOnlineID(target)); @@ -666,7 +666,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); @@ -689,7 +689,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nononoo"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nononoo"); AddUntilStep("game lost selection", () => Beatmap.Value is DummyWorkingBeatmap); AddAssert("carousel lost selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); @@ -1135,7 +1135,7 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); - AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddStep("select all", () => InputManager.Keys(PlatformAction.SelectAll)); AddStep("press ctrl-x", () => { @@ -1144,7 +1144,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.ReleaseKey(Key.ControlLeft); }); - AddAssert("filter text cleared", () => songSelect!.FilterControl.ChildrenOfType().First().Text, () => Is.Empty); + AddAssert("filter text cleared", () => songSelect!.FilterControl.ChildrenOfType().First().Text, () => Is.Empty); } private void waitForInitialSelection() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs index 81b692004b..f2313022ec 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs @@ -9,6 +9,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; @@ -19,11 +20,15 @@ namespace osu.Game.Tests.Visual.UserInterface { private DialogOverlay overlay; + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay()); + } + [Test] public void TestBasic() { - AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay()); - TestPopupDialog firstDialog = null; TestPopupDialog secondDialog = null; @@ -84,7 +89,31 @@ namespace osu.Game.Tests.Visual.UserInterface })); AddAssert("second dialog displayed", () => overlay.CurrentDialog == secondDialog); - AddAssert("first dialog is not part of hierarchy", () => firstDialog.Parent == null); + AddUntilStep("first dialog is not part of hierarchy", () => firstDialog.Parent == null); + } + + [Test] + public void TestTooMuchText() + { + AddStep("dialog #1", () => overlay.Push(new TestPopupDialog + { + Icon = FontAwesome.Regular.TrashAlt, + HeaderText = @"Confirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion of", + BodyText = @"Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver. ", + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"I never want to see this again.", + Action = () => Console.WriteLine(@"OK"), + }, + new PopupDialogCancelButton + { + Text = @"Firetruck, I still want quick ranks!", + Action = () => Console.WriteLine(@"Cancel"), + }, + }, + })); } [Test] @@ -92,7 +121,7 @@ namespace osu.Game.Tests.Visual.UserInterface { PopupDialog dialog = null; - AddStep("create dialog overlay", () => overlay = new SlowLoadingDialogOverlay()); + AddStep("create slow loading dialog overlay", () => overlay = new SlowLoadingDialogOverlay()); AddStep("start loading overlay", () => LoadComponentAsync(overlay, Add)); @@ -128,8 +157,6 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestDismissBeforePush() { - AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay()); - TestPopupDialog testDialog = null; AddStep("dismissed dialog push", () => { @@ -146,8 +173,6 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestDismissBeforePushViaButtonPress() { - AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay()); - TestPopupDialog testDialog = null; AddStep("dismissed dialog push", () => { @@ -163,7 +188,7 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddAssert("no dialog pushed", () => overlay.CurrentDialog == null); - AddAssert("dialog is not part of hierarchy", () => testDialog.Parent == null); + AddUntilStep("dialog is not part of hierarchy", () => testDialog.Parent == null); } private partial class TestPopupDialog : PopupDialog diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs index 4e1bf1390a..a95bb2c9e3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs @@ -9,6 +9,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Select; +using osu.Game.Utils; namespace osu.Game.Tests.Visual.UserInterface { @@ -74,7 +75,7 @@ namespace osu.Game.Tests.Visual.UserInterface private bool assertModsMultiplier(IEnumerable mods) { double multiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); - string expectedValue = multiplier.Equals(1.0) ? string.Empty : $"{multiplier:N2}x"; + string expectedValue = multiplier == 1 ? string.Empty : ModUtils.FormatScoreMultiplier(multiplier).ToString(); return expectedValue == footerButtonMods.MultiplierText.Current.Value; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index f0822ce2a8..4b101a52f4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Mods; @@ -38,6 +39,9 @@ namespace osu.Game.Tests.Visual.UserInterface private TestModSelectOverlay modSelectOverlay = null!; + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + [BackgroundDependencyLoader] private void load() { @@ -566,17 +570,58 @@ namespace osu.Game.Tests.Visual.UserInterface } [Test] - public void TestSearchFocusChangeViaKey() + public void TestTextSearchActiveByDefault() { + AddStep("text search starts active", () => configManager.SetValue(OsuSetting.ModSelectTextSearchStartsActive, true)); createScreen(); - const Key focus_switch_key = Key.Tab; + AddUntilStep("search text box focused", () => modSelectOverlay.SearchTextBox.HasFocus); - AddStep("press tab", () => InputManager.Key(focus_switch_key)); - AddAssert("focused", () => modSelectOverlay.SearchTextBox.HasFocus); + AddStep("press tab", () => InputManager.Key(Key.Tab)); + AddAssert("search text box unfocused", () => !modSelectOverlay.SearchTextBox.HasFocus); - AddStep("press tab", () => InputManager.Key(focus_switch_key)); - AddAssert("lost focus", () => !modSelectOverlay.SearchTextBox.HasFocus); + AddStep("press tab", () => InputManager.Key(Key.Tab)); + AddAssert("search text box focused", () => modSelectOverlay.SearchTextBox.HasFocus); + } + + [Test] + public void TestTextSearchNotActiveByDefault() + { + AddStep("text search does not start active", () => configManager.SetValue(OsuSetting.ModSelectTextSearchStartsActive, false)); + createScreen(); + + AddUntilStep("search text box not focused", () => !modSelectOverlay.SearchTextBox.HasFocus); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + AddAssert("search text box focused", () => modSelectOverlay.SearchTextBox.HasFocus); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + AddAssert("search text box unfocused", () => !modSelectOverlay.SearchTextBox.HasFocus); + } + + [Test] + public void TestTextSearchDoesNotBlockCustomisationPanelKeyboardInteractions() + { + AddStep("text search starts active", () => configManager.SetValue(OsuSetting.ModSelectTextSearchStartsActive, true)); + createScreen(); + + AddUntilStep("search text box focused", () => modSelectOverlay.SearchTextBox.HasFocus); + + AddStep("select DT", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime() }); + AddAssert("DT selected", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value), () => Is.EqualTo(1)); + + AddStep("open customisation area", () => modSelectOverlay.CustomisationButton!.TriggerClick()); + assertCustomisationToggleState(false, true); + AddStep("hover over mod settings slider", () => + { + var slider = modSelectOverlay.ChildrenOfType().Single().ChildrenOfType>().First(); + InputManager.MoveMouseTo(slider); + }); + AddStep("press right arrow", () => InputManager.PressKey(Key.Right)); + AddAssert("DT speed changed", () => !SelectedMods.Value.OfType().Single().SpeedChange.IsDefault); + + AddStep("close customisation area", () => InputManager.PressKey(Key.Escape)); + AddUntilStep("search text box reacquired focus", () => modSelectOverlay.SearchTextBox.HasFocus); } [Test] @@ -787,7 +832,8 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.MoveMouseTo(this.ChildrenOfType().Single(preset => preset.Preset.Value.Name == "Half Time 0.5x")); InputManager.Click(MouseButton.Left); }); - AddAssert("difficulty multiplier display shows correct value", () => modSelectOverlay.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(0.5)); + AddAssert("difficulty multiplier display shows correct value", + () => modSelectOverlay.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(0.1).Within(Precision.DOUBLE_EPSILON)); // this is highly unorthodox in a test, but because the `ModSettingChangeTracker` machinery heavily leans on events and object disposal and re-creation, // it is instrumental in the reproduction of the failure scenario that this test is supposed to cover. @@ -796,7 +842,8 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("open customisation area", () => modSelectOverlay.CustomisationButton!.TriggerClick()); AddStep("reset half time speed to default", () => modSelectOverlay.ChildrenOfType().Single() .ChildrenOfType>().Single().TriggerClick()); - AddUntilStep("difficulty multiplier display shows correct value", () => modSelectOverlay.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(0.7)); + AddUntilStep("difficulty multiplier display shows correct value", + () => modSelectOverlay.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(0.3).Within(Precision.DOUBLE_EPSILON)); } private void waitForColumnLoad() => AddUntilStep("all column content loaded", () => diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs index b0548d7e9f..1678890b73 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs @@ -1,9 +1,17 @@ // 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; namespace osu.Game.Tests.Visual.UserInterface { @@ -13,8 +21,29 @@ namespace osu.Game.Tests.Visual.UserInterface new OsuEnumDropdown { Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Origin = Anchor.TopCentre, Width = 150 }; + + [Test] + // todo: this can be written much better if ThemeComparisonTestScene has a manual input manager + public void TestBackAction() + { + AddStep("open", () => dropdown().ChildrenOfType().Single().Open()); + AddStep("press back", () => dropdown().OnPressed(new KeyBindingPressEvent(new InputState(), GlobalAction.Back))); + AddAssert("closed", () => dropdown().ChildrenOfType().Single().State == MenuState.Closed); + + AddStep("open", () => dropdown().ChildrenOfType().Single().Open()); + AddStep("type something", () => dropdown().ChildrenOfType().Single().SearchTerm.Value = "something"); + AddAssert("search bar visible", () => dropdown().ChildrenOfType().Single().State.Value == Visibility.Visible); + AddStep("press back", () => dropdown().OnPressed(new KeyBindingPressEvent(new InputState(), GlobalAction.Back))); + AddAssert("text clear", () => dropdown().ChildrenOfType().Single().SearchTerm.Value == string.Empty); + AddAssert("search bar hidden", () => dropdown().ChildrenOfType().Single().State.Value == Visibility.Hidden); + AddAssert("still open", () => dropdown().ChildrenOfType().Single().State == MenuState.Open); + AddStep("press back", () => dropdown().OnPressed(new KeyBindingPressEvent(new InputState(), GlobalAction.Back))); + AddAssert("closed", () => dropdown().ChildrenOfType().Single().State == MenuState.Closed); + + OsuEnumDropdown dropdown() => this.ChildrenOfType>().First(); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs index 55a04b129c..5db7223bdf 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs @@ -154,7 +154,7 @@ namespace osu.Game.Tests.Visual.UserInterface public TestTitle() { Title = "title"; - Icon = HexaconsIcons.Devtools; + Icon = OsuIcon.ChangelogB; } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs index 9537ab63be..96d19911bd 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs @@ -1,10 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using NUnit.Framework; -using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Game.Overlays.Dialog; @@ -15,24 +12,25 @@ namespace osu.Game.Tests.Visual.UserInterface { public partial class TestScenePopupDialog : OsuManualInputManagerTestScene { - private TestPopupDialog dialog; + private TestPopupDialog dialog = null!; [SetUpSteps] public void SetUpSteps() { AddStep("new popup", () => { - Add(dialog = new TestPopupDialog + Child = dialog = new TestPopupDialog { - RelativeSizeAxes = Axes.Both, State = { Value = Framework.Graphics.Containers.Visibility.Visible }, - }); + }; }); } [Test] public void TestDangerousButton([Values(false, true)] bool atEdge) { + AddStep("finish transforms", () => dialog.FinishTransforms(true)); + if (atEdge) { AddStep("move mouse to button edge", () => diff --git a/osu.Game.Tournament/Models/TournamentBeatmap.cs b/osu.Game.Tournament/Models/TournamentBeatmap.cs index 7f57b6a151..a7ba5b7db1 100644 --- a/osu.Game.Tournament/Models/TournamentBeatmap.cs +++ b/osu.Game.Tournament/Models/TournamentBeatmap.cs @@ -21,6 +21,10 @@ namespace osu.Game.Tournament.Models public double StarRating { get; set; } + public int EndTimeObjectCount { get; set; } + + public int TotalObjectCount { get; set; } + public IBeatmapMetadataInfo Metadata { get; set; } = new BeatmapMetadata(); public IBeatmapDifficultyInfo Difficulty { get; set; } = new BeatmapDifficulty(); @@ -41,6 +45,8 @@ namespace osu.Game.Tournament.Models Metadata = beatmap.Metadata; Difficulty = beatmap.Difficulty; Covers = beatmap.BeatmapSet?.Covers ?? new BeatmapSetOnlineCovers(); + EndTimeObjectCount = beatmap.EndTimeObjectCount; + TotalObjectCount = beatmap.TotalObjectCount; } public bool Equals(IBeatmapInfo? other) => other is TournamentBeatmap b && this.MatchesOnlineID(b); diff --git a/osu.Game/Audio/PreviewTrack.cs b/osu.Game/Audio/PreviewTrack.cs index d625566ee7..961990a1bd 100644 --- a/osu.Game/Audio/PreviewTrack.cs +++ b/osu.Game/Audio/PreviewTrack.cs @@ -96,10 +96,14 @@ namespace osu.Game.Audio hasStarted = false; - Track.Stop(); + // This pre-check is important, fixes a BASS deadlock in some scenarios. + if (!Track.HasCompleted) + { + Track.Stop(); - // Ensure the track is reset immediately on stopping, so the next time it is started it has a correct time value. - Track.Seek(0); + // Ensure the track is reset immediately on stopping, so the next time it is started it has a correct time value. + Track.Seek(0); + } Stopped?.Invoke(); } diff --git a/osu.Game/BackgroundDataStoreProcessor.cs b/osu.Game/BackgroundDataStoreProcessor.cs index 90e55dea6d..fc7db13d41 100644 --- a/osu.Game/BackgroundDataStoreProcessor.cs +++ b/osu.Game/BackgroundDataStoreProcessor.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -14,6 +13,7 @@ using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -29,6 +29,8 @@ namespace osu.Game /// public partial class BackgroundDataStoreProcessor : Component { + protected Task ProcessingTask { get; private set; } = null!; + [Resolved] private RulesetStore rulesetStore { get; set; } = null!; @@ -62,12 +64,14 @@ namespace osu.Game { base.LoadComplete(); - Task.Factory.StartNew(() => + ProcessingTask = Task.Factory.StartNew(() => { Logger.Log("Beginning background data store processing.."); checkForOutdatedStarRatings(); processBeatmapSetsWithMissingMetrics(); + // Note that the previous method will also update these on a fresh run. + processBeatmapsWithMissingObjectCounts(); processScoresWithMissingStatistics(); convertLegacyTotalScoreToStandardised(); }, TaskCreationOptions.LongRunning).ContinueWith(t => @@ -134,28 +138,34 @@ namespace osu.Game // of other possible ways), but for now avoid queueing if the user isn't logged in at startup. if (api.IsLoggedIn) { - foreach (var b in r.All().Where(b => b.StarRating < 0 || (b.OnlineID > 0 && b.LastOnlineUpdate == null))) - { - Debug.Assert(b.BeatmapSet != null); - beatmapSetIds.Add(b.BeatmapSet.ID); - } + foreach (var b in r.All().Where(b => (b.StarRating < 0 || (b.OnlineID > 0 && b.LastOnlineUpdate == null)) && b.BeatmapSet != null)) + beatmapSetIds.Add(b.BeatmapSet!.ID); } else { - foreach (var b in r.All().Where(b => b.StarRating < 0)) - { - Debug.Assert(b.BeatmapSet != null); - beatmapSetIds.Add(b.BeatmapSet.ID); - } + foreach (var b in r.All().Where(b => b.StarRating < 0 && b.BeatmapSet != null)) + beatmapSetIds.Add(b.BeatmapSet!.ID); } }); + if (beatmapSetIds.Count == 0) + return; + Logger.Log($"Found {beatmapSetIds.Count} beatmap sets which require reprocessing."); - int i = 0; + // Technically this is doing more than just star ratings, but easier for the end user to understand. + var notification = showProgressNotification(beatmapSetIds.Count, "Reprocessing star rating for beatmaps", "beatmaps' star ratings have been updated"); + + int processedCount = 0; + int failedCount = 0; foreach (var id in beatmapSetIds) { + if (notification?.State == ProgressNotificationState.Cancelled) + break; + + updateNotificationProgress(notification, processedCount, beatmapSetIds.Count); + sleepIfRequired(); realmAccess.Run(r => @@ -166,16 +176,73 @@ namespace osu.Game { try { - Logger.Log($"Background processing {set} ({++i} / {beatmapSetIds.Count})"); beatmapUpdater.Process(set); + ++processedCount; } catch (Exception e) { Logger.Log($"Background processing failed on {set}: {e}"); + ++failedCount; } } }); } + + completeNotification(notification, processedCount, beatmapSetIds.Count, failedCount); + } + + private void processBeatmapsWithMissingObjectCounts() + { + Logger.Log("Querying for beatmaps with missing hitobject counts to reprocess..."); + + HashSet beatmapIds = new HashSet(); + + realmAccess.Run(r => + { + foreach (var b in r.All().Where(b => b.TotalObjectCount < 0 || b.EndTimeObjectCount < 0)) + beatmapIds.Add(b.ID); + }); + + if (beatmapIds.Count == 0) + return; + + Logger.Log($"Found {beatmapIds.Count} beatmaps which require statistics population."); + + var notification = showProgressNotification(beatmapIds.Count, "Populating missing statistics for beatmaps", "beatmaps have been populated with missing statistics"); + + int processedCount = 0; + int failedCount = 0; + + foreach (var id in beatmapIds) + { + if (notification?.State == ProgressNotificationState.Cancelled) + break; + + updateNotificationProgress(notification, processedCount, beatmapIds.Count); + + sleepIfRequired(); + + realmAccess.Run(r => + { + var beatmap = r.Find(id); + + if (beatmap != null) + { + try + { + beatmapUpdater.ProcessObjectCounts(beatmap); + ++processedCount; + } + catch (Exception e) + { + Logger.Log($"Background processing failed on {beatmap}: {e}"); + ++failedCount; + } + } + }); + } + + completeNotification(notification, processedCount, beatmapIds.Count, failedCount); } private void processScoresWithMissingStatistics() @@ -197,10 +264,23 @@ namespace osu.Game } }); - Logger.Log($"Found {scoreIds.Count} scores which require reprocessing."); + if (scoreIds.Count == 0) + return; + + Logger.Log($"Found {scoreIds.Count} scores which require statistics population."); + + var notification = showProgressNotification(scoreIds.Count, "Populating missing statistics for scores", "scores have been populated with missing statistics"); + + int processedCount = 0; + int failedCount = 0; foreach (var id in scoreIds) { + if (notification?.State == ProgressNotificationState.Cancelled) + break; + + updateNotificationProgress(notification, processedCount, scoreIds.Count); + sleepIfRequired(); try @@ -216,7 +296,7 @@ namespace osu.Game r.Find(id)!.MaximumStatisticsJson = JsonConvert.SerializeObject(score.MaximumStatistics); }); - Logger.Log($"Populated maximum statistics for score {id}"); + ++processedCount; } catch (ObjectDisposedException) { @@ -226,57 +306,59 @@ namespace osu.Game { Logger.Log(@$"Failed to populate maximum statistics for {id}: {e}"); realmAccess.Write(r => r.Find(id)!.BackgroundReprocessingFailed = true); + ++failedCount; } } + + completeNotification(notification, processedCount, scoreIds.Count, failedCount); } private void convertLegacyTotalScoreToStandardised() { Logger.Log("Querying for scores that need total score conversion..."); - HashSet scoreIds = realmAccess.Run(r => new HashSet(r.All() - .Where(s => !s.BackgroundReprocessingFailed && s.BeatmapInfo != null - && (s.TotalScoreVersion == 30000002 - || s.TotalScoreVersion == 30000003)) - .AsEnumerable().Select(s => s.ID))); + HashSet scoreIds = realmAccess.Run(r => new HashSet( + r.All() + .Where(s => !s.BackgroundReprocessingFailed + && s.BeatmapInfo != null + && s.IsLegacyScore + && s.TotalScoreVersion < LegacyScoreEncoder.LATEST_VERSION) + .AsEnumerable() + // must be done after materialisation, as realm doesn't want to support + // nested property predicates + .Where(s => s.Ruleset.IsLegacyRuleset()) + .Select(s => s.ID))); Logger.Log($"Found {scoreIds.Count} scores which require total score conversion."); if (scoreIds.Count == 0) return; - ProgressNotification notification = new ProgressNotification { State = ProgressNotificationState.Active }; - - notificationOverlay?.Post(notification); + var notification = showProgressNotification(scoreIds.Count, "Upgrading scores to new scoring algorithm", "scores have been upgraded to the new scoring algorithm"); int processedCount = 0; int failedCount = 0; foreach (var id in scoreIds) { - if (notification.State == ProgressNotificationState.Cancelled) + if (notification?.State == ProgressNotificationState.Cancelled) break; - notification.Text = $"Upgrading scores to new scoring algorithm ({processedCount} of {scoreIds.Count})"; - notification.Progress = (float)processedCount / scoreIds.Count; + updateNotificationProgress(notification, processedCount, scoreIds.Count); sleepIfRequired(); try { - var score = scoreManager.Query(s => s.ID == id); - long newTotalScore = StandardisedScoreMigrationTools.ConvertFromLegacyTotalScore(score, beatmapManager); - // Can't use async overload because we're not on the update thread. // ReSharper disable once MethodHasAsyncOverload realmAccess.Write(r => { ScoreInfo s = r.Find(id)!; - s.TotalScore = newTotalScore; + StandardisedScoreMigrationTools.UpdateFromLegacy(s, beatmapManager); s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION; }); - Logger.Log($"Converted total score for score {id}"); ++processedCount; } catch (ObjectDisposedException) @@ -291,24 +373,64 @@ namespace osu.Game } } - if (processedCount == scoreIds.Count) + completeNotification(notification, processedCount, scoreIds.Count, failedCount); + } + + private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount) + { + if (notification == null) + return; + + notification.Text = notification.Text.ToString().Split('(').First().TrimEnd() + $" ({processedCount} of {totalCount})"; + notification.Progress = (float)processedCount / totalCount; + + if (processedCount % 100 == 0) + Logger.Log(notification.Text.ToString()); + } + + private void completeNotification(ProgressNotification? notification, int processedCount, int totalCount, int? failedCount = null) + { + if (notification == null) + return; + + if (processedCount == totalCount) { - notification.CompletionText = $"{processedCount} score(s) have been upgraded to the new scoring algorithm"; + notification.CompletionText = $"{processedCount} {notification.CompletionText}"; notification.Progress = 1; notification.State = ProgressNotificationState.Completed; } else { - notification.Text = $"{processedCount} of {scoreIds.Count} score(s) have been upgraded to the new scoring algorithm."; + notification.Text = $"{processedCount} of {totalCount} {notification.CompletionText}"; // We may have arrived here due to user cancellation or completion with failures. if (failedCount > 0) - notification.Text += $" Check logs for issues with {failedCount} failed upgrades."; + notification.Text += $" Check logs for issues with {failedCount} failed items."; notification.State = ProgressNotificationState.Cancelled; } } + private ProgressNotification? showProgressNotification(int totalCount, string running, string completed) + { + if (notificationOverlay == null) + return null; + + if (totalCount < 10) + return null; + + ProgressNotification notification = new ProgressNotification + { + Text = running, + CompletionText = completed, + State = ProgressNotificationState.Active + }; + + notificationOverlay?.Post(notification); + + return notification; + } + private void sleepIfRequired() { while (localUserPlayInfo?.IsPlaying.Value == true) diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index e89e5339e1..7bb52eef52 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -20,6 +20,7 @@ using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects.Types; using Realms; namespace osu.Game.Beatmaps @@ -387,7 +388,7 @@ namespace osu.Game.Beatmaps OverallDifficulty = decodedDifficulty.OverallDifficulty, ApproachRate = decodedDifficulty.ApproachRate, SliderMultiplier = decodedDifficulty.SliderMultiplier, - SliderTickRate = decodedDifficulty.SliderTickRate, + SliderTickRate = decodedDifficulty.SliderTickRate }; var metadata = new BeatmapMetadata @@ -425,6 +426,8 @@ namespace osu.Game.Beatmaps GridSize = decodedInfo.GridSize, TimelineZoom = decodedInfo.TimelineZoom, MD5Hash = memoryStream.ComputeMD5Hash(), + EndTimeObjectCount = decoded.HitObjects.Count(h => h is IHasDuration), + TotalObjectCount = decoded.HitObjects.Count }; beatmaps.Add(beatmap); diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index c1aeec1f71..425fd98d27 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -120,6 +120,10 @@ namespace osu.Game.Beatmaps [JsonIgnore] public bool Hidden { get; set; } + public int EndTimeObjectCount { get; set; } = -1; + + public int TotalObjectCount { get; set; } = -1; + /// /// Reset any fetched online linking information (and history). /// diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index 56bfdc5001..e897d28916 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; @@ -10,6 +11,7 @@ using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Database; using osu.Game.Online.API; +using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Beatmaps { @@ -44,7 +46,8 @@ namespace osu.Game.Beatmaps public void Queue(Live beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) { Logger.Log($"Queueing change for local beatmap {beatmapSet}"); - Task.Factory.StartNew(() => beatmapSet.PerformRead(b => Process(b, lookupScope)), default, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + Task.Factory.StartNew(() => beatmapSet.PerformRead(b => Process(b, lookupScope)), default, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, + updateScheduler); } /// @@ -74,12 +77,29 @@ namespace osu.Game.Beatmaps beatmap.StarRating = calculator.Calculate().StarRating; beatmap.Length = working.Beatmap.CalculatePlayableLength(); beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength(); + beatmap.EndTimeObjectCount = working.Beatmap.HitObjects.Count(h => h is IHasDuration); + beatmap.TotalObjectCount = working.Beatmap.HitObjects.Count; } // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. workingBeatmapCache.Invalidate(beatmapSet); }); + public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapInfo.Realm!.Write(_ => + { + // Before we use below, we want to invalidate. + workingBeatmapCache.Invalidate(beatmapInfo); + + var working = workingBeatmapCache.GetWorkingBeatmap(beatmapInfo); + var beatmap = working.Beatmap; + + beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration); + beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count; + + // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. + workingBeatmapCache.Invalidate(beatmapInfo); + }); + #region Implementation of IDisposable public void Dispose() diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index df8953d57c..55ef6f705e 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -38,6 +39,8 @@ namespace osu.Game.Beatmaps.Drawables private readonly Bindable displayedStars = new BindableDouble(); + private readonly Container textContainer; + /// /// The currently displayed stars of this display wrapped in a bindable. /// This bindable gets transformed on change rather than instantaneous, if animation is enabled. @@ -116,15 +119,19 @@ namespace osu.Game.Beatmaps.Drawables Size = new Vector2(8f), }, Empty(), - starsText = new OsuSpriteText + textContainer = new Container { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Margin = new MarginPadding { Bottom = 1.5f }, - // todo: this should be size: 12f, but to match up with the design, it needs to be 14.4f - // see https://github.com/ppy/osu-framework/issues/3271. - Font = OsuFont.Torus.With(size: 14.4f, weight: FontWeight.Bold), - Shadow = false, + AutoSizeAxes = Axes.Y, + Child = starsText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Bottom = 1.5f }, + // todo: this should be size: 12f, but to match up with the design, it needs to be 14.4f + // see https://github.com/ppy/osu-framework/issues/3271. + Font = OsuFont.Torus.With(size: 14.4f, weight: FontWeight.Bold), + Shadow = false, + }, }, } } @@ -155,6 +162,11 @@ namespace osu.Game.Beatmaps.Drawables 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); + + // In order to avoid autosize throwing the width of these displays all over the place, + // let's lock in some sane defaults for the text width based on how many digits we're + // displaying. + textContainer.Width = 24 + Math.Max(starsText.Text.ToString().Length - 4, 0) * 6; }, true); } } diff --git a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs index e7a3d87d0a..48f6564084 100644 --- a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; + namespace osu.Game.Beatmaps { /// @@ -92,5 +94,21 @@ namespace osu.Game.Beatmaps /// Value to which the difficulty value maps in the specified range. static double DifficultyRange(double difficulty, (double od0, double od5, double od10) range) => DifficultyRange(difficulty, range.od0, range.od5, range.od10); + + /// + /// Inverse function to . + /// Maps a value returned by the function above back to the difficulty that produced it. + /// + /// The difficulty-dependent value to be unmapped. + /// Minimum of the resulting range which will be achieved by a difficulty value of 0. + /// Midpoint of the resulting range which will be achieved by a difficulty value of 5. + /// Maximum of the resulting range which will be achieved by a difficulty value of 10. + /// Value to which the difficulty value maps in the specified range. + static double InverseDifficultyRange(double difficultyValue, double diff0, double diff5, double diff10) + { + return Math.Sign(difficultyValue - diff5) == Math.Sign(diff10 - diff5) + ? (difficultyValue - diff5) / (diff10 - diff5) * 5 + 5 + : (difficultyValue - diff5) / (diff5 - diff0) * 5 + 5; + } } } diff --git a/osu.Game/Beatmaps/IBeatmapInfo.cs b/osu.Game/Beatmaps/IBeatmapInfo.cs index b8c69cc525..04c2017ded 100644 --- a/osu.Game/Beatmaps/IBeatmapInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapInfo.cs @@ -59,7 +59,23 @@ namespace osu.Game.Beatmaps /// /// The basic star rating for this beatmap (with no mods applied). + /// Defaults to -1 (meaning not-yet-calculated). /// double StarRating { get; } + + /// + /// The number of hitobjects in the beatmap with a distinct end time. + /// Defaults to -1 (meaning not-yet-calculated). + /// + /// + /// Canonically, these are hitobjects are either sliders or spinners. + /// + int EndTimeObjectCount { get; } + + /// + /// The total number of hitobjects in the beatmap. + /// Defaults to -1 (meaning not-yet-calculated). + /// + int TotalObjectCount { get; } } } diff --git a/osu.Game/Collections/CollectionDropdown.cs b/osu.Game/Collections/CollectionDropdown.cs index e435992381..8d83ed3ec9 100644 --- a/osu.Game/Collections/CollectionDropdown.cs +++ b/osu.Game/Collections/CollectionDropdown.cs @@ -43,11 +43,14 @@ namespace osu.Game.Collections private IDisposable? realmSubscription; + private readonly CollectionFilterMenuItem allBeatmapsItem = new AllBeatmapsCollectionFilterMenuItem(); + public CollectionDropdown() { ItemSource = filters; - Current.Value = new AllBeatmapsCollectionFilterMenuItem(); + Current.Value = allBeatmapsItem; + AlwaysShowSearchBar = true; } protected override void LoadComplete() @@ -61,37 +64,52 @@ namespace osu.Game.Collections private void collectionsChanged(IRealmCollection collections, ChangeSet? changes) { - var selectedItem = SelectedItem?.Value?.Collection; - - var allBeatmaps = new AllBeatmapsCollectionFilterMenuItem(); - - filters.Clear(); - filters.Add(allBeatmaps); - filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm)))); - - if (ShowManageCollectionsItem) - filters.Add(new ManageCollectionsFilterMenuItem()); - - // This current update and schedule is required to work around dropdown headers not updating text even when the selected item - // changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue - // a warning that it's going to be a frustrating journey. - Current.Value = allBeatmaps; - Schedule(() => + if (changes == null) { - // current may have changed before the scheduled call is run. - if (Current.Value != allBeatmaps) - return; - - Current.Value = filters.SingleOrDefault(f => f.Collection != null && f.Collection.ID == selectedItem?.ID) ?? filters[0]; - }); - - // Trigger a re-filter if the current item was in the change set. - if (selectedItem != null && changes != null) + filters.Clear(); + filters.Add(allBeatmapsItem); + filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm)))); + if (ShowManageCollectionsItem) + filters.Add(new ManageCollectionsFilterMenuItem()); + } + else { - foreach (int index in changes.ModifiedIndices) + foreach (int i in changes.DeletedIndices) + filters.RemoveAt(i + 1); + + foreach (int i in changes.InsertedIndices) + filters.Insert(i + 1, new CollectionFilterMenuItem(collections[i].ToLive(realm))); + + var selectedItem = SelectedItem?.Value; + + foreach (int i in changes.NewModifiedIndices) { - if (collections[index].ID == selectedItem.ID) + var updatedItem = collections[i]; + + // This is responsible for updating the state of the +/- button and the collection's name. + // TODO: we can probably make the menu items update with changes to avoid this. + filters.RemoveAt(i + 1); + filters.Insert(i + 1, new CollectionFilterMenuItem(updatedItem.ToLive(realm))); + + if (updatedItem.ID == selectedItem?.Collection?.ID) + { + // This current update and schedule is required to work around dropdown headers not updating text even when the selected item + // changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue + // a warning that it's going to be a frustrating journey. + Current.Value = allBeatmapsItem; + Schedule(() => + { + // current may have changed before the scheduled call is run. + if (Current.Value != allBeatmapsItem) + return; + + Current.Value = filters.SingleOrDefault(f => f.Collection?.ID == selectedItem.Collection?.ID) ?? filters[0]; + }); + + // Trigger an external re-filter if the current item was in the change set. RequestFilter?.Invoke(); + break; + } } } } diff --git a/osu.Game/Collections/CollectionFilterMenuItem.cs b/osu.Game/Collections/CollectionFilterMenuItem.cs index 2ac5784f09..49262ed917 100644 --- a/osu.Game/Collections/CollectionFilterMenuItem.cs +++ b/osu.Game/Collections/CollectionFilterMenuItem.cs @@ -37,22 +37,17 @@ namespace osu.Game.Collections CollectionName = name; } - public bool Equals(CollectionFilterMenuItem? other) + public virtual bool Equals(CollectionFilterMenuItem? other) { - if (other == null) - return false; + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; - // collections may have the same name, so compare first on reference equality. - // this relies on the assumption that only one instance of the BeatmapCollection exists game-wide, managed by CollectionManager. - if (Collection != null) - return Collection.ID == other.Collection?.ID; + if (Collection == null) return false; - // fallback to name-based comparison. - // this is required for special dropdown items which don't have a collection (all beatmaps / manage collections items below). - return CollectionName == other.CollectionName; + return Collection.ID == other.Collection?.ID; } - public override int GetHashCode() => CollectionName.GetHashCode(); + public override int GetHashCode() => Collection?.ID.GetHashCode() ?? 0; } public class AllBeatmapsCollectionFilterMenuItem : CollectionFilterMenuItem @@ -61,6 +56,10 @@ namespace osu.Game.Collections : base("All beatmaps") { } + + public override bool Equals(CollectionFilterMenuItem? other) => other is AllBeatmapsCollectionFilterMenuItem; + + public override int GetHashCode() => 1; } public class ManageCollectionsFilterMenuItem : CollectionFilterMenuItem @@ -69,5 +68,9 @@ namespace osu.Game.Collections : base("Manage collections...") { } + + public override bool Equals(CollectionFilterMenuItem? other) => other is ManageCollectionsFilterMenuItem; + + public override int GetHashCode() => 2; } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 6ef55ab919..23686db1f8 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -20,6 +20,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Skinning; +using osu.Game.Users; namespace osu.Game.Configuration { @@ -37,7 +38,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Ruleset, string.Empty); SetDefault(OsuSetting.Skin, SkinInfo.ARGON_SKIN.ToString()); - SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); + SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Local); SetDefault(OsuSetting.BeatmapDetailModsFilter, false); SetDefault(OsuSetting.ShowConvertedBeatmaps, true); @@ -49,6 +50,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); SetDefault(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Sequential); + SetDefault(OsuSetting.ModSelectTextSearchStartsActive, true); SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); @@ -95,6 +97,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.MenuVoice, true); SetDefault(OsuSetting.MenuMusic, true); + SetDefault(OsuSetting.MenuTips, true); SetDefault(OsuSetting.AudioOffset, 0, -500.0, 500.0, 1); @@ -191,6 +194,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.LastProcessedMetadataId, -1); SetDefault(OsuSetting.ComboColourNormalisationAmount, 0.2f, 0f, 1f, 0.01f); + SetDefault(OsuSetting.UserOnlineStatus, null); } protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) @@ -349,6 +353,7 @@ namespace osu.Game.Configuration VolumeInactive, MenuMusic, MenuVoice, + MenuTips, CursorRotation, MenuParallax, Prefer24HourTime, @@ -416,5 +421,7 @@ namespace osu.Game.Configuration AutomaticallyDownloadMissingBeatmaps, EditorShowSpeedChanges, TouchDisableGameplayTaps, + ModSelectTextSearchStartsActive, + UserOnlineStatus, } } diff --git a/osu.Game/Configuration/SessionAverageHitErrorTracker.cs b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs new file mode 100644 index 0000000000..cd21eb6fa8 --- /dev/null +++ b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Configuration +{ + /// + /// Tracks the local user's average hit error during the ongoing play session. + /// + [Cached] + public partial class SessionAverageHitErrorTracker : Component + { + public IBindableList AverageHitErrorHistory => averageHitErrorHistory; + private readonly BindableList averageHitErrorHistory = new BindableList(); + + private readonly Bindable latestScore = new Bindable(); + + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(SessionStatics statics) + { + statics.BindWith(Static.LastLocalUserScore, latestScore); + latestScore.BindValueChanged(score => calculateAverageHitError(score.NewValue), true); + } + + private void calculateAverageHitError(ScoreInfo? newScore) + { + if (newScore == null) + return; + + if (newScore.Mods.Any(m => !m.UserPlayable || m is IHasNoTimedInputs)) + return; + + if (newScore.HitEvents.Count < 10) + return; + + if (newScore.HitEvents.CalculateAverageHitError() is not double averageError) + return; + + // keep a sane maximum number of entries. + if (averageHitErrorHistory.Count >= 50) + averageHitErrorHistory.RemoveAt(0); + + double globalOffset = configManager.Get(OsuSetting.AudioOffset); + averageHitErrorHistory.Add(new DataPoint(averageError, globalOffset)); + } + + public void ClearHistory() => averageHitErrorHistory.Clear(); + + public readonly struct DataPoint + { + public double AverageHitError { get; } + public double GlobalAudioOffset { get; } + + public double SuggestedGlobalAudioOffset => GlobalAudioOffset - AverageHitError; + + public DataPoint(double averageHitError, double globalOffset) + { + AverageHitError = averageHitError; + GlobalAudioOffset = globalOffset; + } + } + } +} diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index 8f0a60b23d..1548b781a7 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -9,6 +9,7 @@ using osu.Game.Input; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Mods; +using osu.Game.Scoring; namespace osu.Game.Configuration { @@ -27,6 +28,7 @@ namespace osu.Game.Configuration SetDefault(Static.LastModSelectPanelSamplePlaybackTime, (double?)null); SetDefault(Static.SeasonalBackgrounds, null); SetDefault(Static.TouchInputActive, RuntimeInfo.IsMobile); + SetDefault(Static.LastLocalUserScore, null); } /// @@ -73,5 +75,10 @@ namespace osu.Game.Configuration /// Used in touchscreen detection scenarios (). /// TouchInputActive, + + /// + /// Stores the local user's last score (can be completed or aborted). + /// + LastLocalUserScore, } } diff --git a/osu.Game/Database/LegacyImportManager.cs b/osu.Game/Database/LegacyImportManager.cs index 20738f859e..7e1641d16f 100644 --- a/osu.Game/Database/LegacyImportManager.cs +++ b/osu.Game/Database/LegacyImportManager.cs @@ -3,6 +3,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework; @@ -54,6 +57,49 @@ namespace osu.Game.Database public void UpdateStorage(string stablePath) => cachedStorage = new StableStorage(stablePath, gameHost as DesktopGameHost); + /// + /// Checks whether a valid location to run a stable import from can be determined starting from the supplied . + /// + /// The directory to check for stable import eligibility. + /// + /// If the return value is , + /// this parameter will contain the to use as the root directory for importing. + /// + public bool IsUsableForStableImport(DirectoryInfo? directory, [NotNullWhen(true)] out DirectoryInfo? stableRoot) + { + if (directory == null) + { + stableRoot = null; + return false; + } + + // A full stable installation will have a configuration file present. + // This is the best case scenario, as it may contain a custom beatmap directory we need to traverse to. + if (directory.GetFiles(@"osu!.*.cfg").Any()) + { + stableRoot = directory; + return true; + } + + // The user may only have their songs or skins folders left. + // We still want to allow them to import based on this. + if (directory.GetDirectories(@"Songs").Any() || directory.GetDirectories(@"Skins").Any()) + { + stableRoot = directory; + return true; + } + + // The user may have traversed *inside* their songs or skins folders. + if (directory.Parent != null && (directory.Name == @"Songs" || directory.Name == @"Skins")) + { + stableRoot = directory.Parent; + return true; + } + + stableRoot = null; + return false; + } + public bool CheckSongsFolderHardLinkAvailability() { var stableStorage = GetCurrentStableStorage(); diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index e9f49ec662..4bd7f36cdd 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -88,8 +88,11 @@ namespace osu.Game.Database /// 34 2023-08-21 Add BackgroundReprocessingFailed flag to ScoreInfo to track upgrade failures. /// 35 2023-10-16 Clear key combinations of keybindings that are assigned to more than one action in a given settings section. /// 36 2023-10-26 Add LegacyOnlineID to ScoreInfo. Move osu_scores_*_high IDs stored in OnlineID to LegacyOnlineID. Reset anomalous OnlineIDs. + /// 38 2023-12-10 Add EndTimeObjectCount and TotalObjectCount to BeatmapInfo. + /// 39 2023-12-19 Migrate any EndTimeObjectCount and TotalObjectCount values of 0 to -1 to better identify non-calculated values. + /// 40 2023-12-21 Add ScoreInfo.Version to keep track of which build scores were set on. /// - private const int schema_version = 36; + private const int schema_version = 40; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -1094,6 +1097,20 @@ namespace osu.Game.Database break; } + + case 39: + foreach (var b in migration.NewRealm.All()) + { + // Either actually no objects, or processing ran and failed. + // Reset to -1 so the next time they become zero we know that processing was attempted. + if (b.TotalObjectCount == 0 && b.EndTimeObjectCount == 0) + { + b.TotalObjectCount = -1; + b.EndTimeObjectCount = -1; + } + } + + break; } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); diff --git a/osu.Game/Database/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs index 1da64d5be8..9683baec69 100644 --- a/osu.Game/Database/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.IO; -using System.Linq; using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Logging; @@ -98,15 +98,11 @@ namespace osu.Game.Database // can potentially be run asynchronously, although we will need to consider operation order for disk deletion vs realm removal. realm.Write(r => { - // TODO: consider using a realm native query to avoid iterating all files (https://github.com/realm/realm-dotnet/issues/2659#issuecomment-927823707) - var files = r.All().ToList(); - - foreach (var file in files) + foreach (var file in r.All().Filter(@$"{nameof(RealmFile.Usages)}.@count = 0")) { totalFiles++; - if (file.BacklinksCount > 0) - continue; + Debug.Assert(file.BacklinksCount == 0); try { diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index 3d48a5d233..8c73806cb5 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -57,14 +57,14 @@ namespace osu.Game.Database // We are constructing a "best possible" score from the statistics provided because it's the best we can do. List sortedHits = score.Statistics .Where(kvp => kvp.Key.AffectsCombo()) - .OrderByDescending(kvp => Judgement.ToNumericResult(kvp.Key)) + .OrderByDescending(kvp => processor.GetBaseScoreForResult(kvp.Key)) .SelectMany(kvp => Enumerable.Repeat(kvp.Key, kvp.Value)) .ToList(); // Attempt to use maximum statistics from the database. var maximumJudgements = score.MaximumStatistics .Where(kvp => kvp.Key.AffectsCombo()) - .OrderByDescending(kvp => Judgement.ToNumericResult(kvp.Key)) + .OrderByDescending(kvp => processor.GetBaseScoreForResult(kvp.Key)) .SelectMany(kvp => Enumerable.Repeat(new FakeJudgement(kvp.Key), kvp.Value)) .ToList(); @@ -169,10 +169,10 @@ namespace osu.Game.Database public static long GetOldStandardised(ScoreInfo score) { double accuracyScore = - (double)score.Statistics.Where(kvp => kvp.Key.AffectsAccuracy()).Sum(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value) - / score.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy()).Sum(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value); + (double)score.Statistics.Where(kvp => kvp.Key.AffectsAccuracy()).Sum(kvp => numericScoreFor(kvp.Key) * kvp.Value) + / score.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy()).Sum(kvp => numericScoreFor(kvp.Key) * kvp.Value); double comboScore = (double)score.MaxCombo / score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Sum(kvp => kvp.Value); - double bonusScore = score.Statistics.Where(kvp => kvp.Key.IsBonus()).Sum(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value); + double bonusScore = score.Statistics.Where(kvp => kvp.Key.IsBonus()).Sum(kvp => numericScoreFor(kvp.Key) * kvp.Value); double accuracyPortion = 0.3; @@ -193,6 +193,65 @@ namespace osu.Game.Database modMultiplier *= mod.ScoreMultiplier; return (long)Math.Round((1000000 * (accuracyPortion * accuracyScore + (1 - accuracyPortion) * comboScore) + bonusScore) * modMultiplier); + + static int numericScoreFor(HitResult result) + { + switch (result) + { + default: + return 0; + + case HitResult.SmallTickHit: + return 10; + + case HitResult.LargeTickHit: + return 30; + + case HitResult.Meh: + return 50; + + case HitResult.Ok: + return 100; + + case HitResult.Good: + return 200; + + case HitResult.Great: + return 300; + + case HitResult.Perfect: + return 315; + + case HitResult.SmallBonus: + return 10; + + case HitResult.LargeBonus: + return 50; + } + } + } + + /// + /// Updates a legacy to standardised scoring. + /// + /// The score to update. + /// A used for lookups. + public static void UpdateFromLegacy(ScoreInfo score, BeatmapManager beatmaps) + { + score.TotalScore = convertFromLegacyTotalScore(score, beatmaps); + score.Accuracy = ComputeAccuracy(score); + } + + /// + /// Updates a legacy to standardised scoring. + /// + /// The score to update. + /// The beatmap difficulty. + /// The legacy scoring attributes for the beatmap which the score was set on. + public static void UpdateFromLegacy(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes) + { + score.TotalScore = convertFromLegacyTotalScore(score, difficulty, attributes); + score.Accuracy = ComputeAccuracy(score); } /// @@ -201,7 +260,7 @@ namespace osu.Game.Database /// The score to convert the total score of. /// A used for lookups. /// The standardised total score. - public static long ConvertFromLegacyTotalScore(ScoreInfo score, BeatmapManager beatmaps) + private static long convertFromLegacyTotalScore(ScoreInfo score, BeatmapManager beatmaps) { if (!score.IsLegacyScore) return score.TotalScore; @@ -224,7 +283,7 @@ namespace osu.Game.Database ILegacyScoreSimulator sv1Simulator = legacyRuleset.CreateLegacyScoreSimulator(); LegacyScoreAttributes attributes = sv1Simulator.Simulate(beatmap, playableBeatmap); - return ConvertFromLegacyTotalScore(score, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), attributes); + return convertFromLegacyTotalScore(score, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), attributes); } /// @@ -234,7 +293,7 @@ namespace osu.Game.Database /// The beatmap difficulty. /// The legacy scoring attributes for the beatmap which the score was set on. /// The standardised total score. - public static long ConvertFromLegacyTotalScore(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes) + private static long convertFromLegacyTotalScore(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes) { if (!score.IsLegacyScore) return score.TotalScore; @@ -249,47 +308,293 @@ namespace osu.Game.Database int maximumLegacyAccuracyScore = attributes.AccuracyScore; long maximumLegacyComboScore = (long)Math.Round(attributes.ComboScore * legacyModMultiplier); double maximumLegacyBonusRatio = attributes.BonusScoreRatio; + long maximumLegacyBonusScore = attributes.BonusScore; - // The part of total score that doesn't include bonus. + double legacyAccScore = maximumLegacyAccuracyScore * score.Accuracy; + + double comboProportion; + + if (maximumLegacyComboScore + maximumLegacyBonusScore > 0) + { + // We can not separate the ComboScore from the BonusScore, so we keep the bonus in the ratio. + comboProportion = Math.Max((double)score.LegacyTotalScore - legacyAccScore, 0) / (maximumLegacyComboScore + maximumLegacyBonusScore); + } + else + { + // Two possible causes: + // the beatmap has no bonus objects *AND* + // either the active mods have a zero mod multiplier, in which case assume 0, + // or the *beatmap* has a zero `difficultyPeppyStars` (or just no combo-giving objects), in which case assume 1. + comboProportion = legacyModMultiplier == 0 ? 0 : 1; + } + + // We assume the bonus proportion only makes up the rest of the score that exceeds maximumLegacyBaseScore. long maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore; - - // The combo proportion is calculated as a proportion of maximumLegacyBaseScore. - double comboProportion = Math.Min(1, (double)score.LegacyTotalScore / maximumLegacyBaseScore); - - // The bonus proportion makes up the rest of the score that exceeds maximumLegacyBaseScore. double bonusProportion = Math.Max(0, ((long)score.LegacyTotalScore - maximumLegacyBaseScore) * maximumLegacyBonusRatio); double modMultiplier = score.Mods.Select(m => m.ScoreMultiplier).Aggregate(1.0, (c, n) => c * n); + long convertedTotalScore; + switch (score.Ruleset.OnlineID) { case 0: - return (long)Math.Round(( - 700000 * comboProportion - + 300000 * Math.Pow(score.Accuracy, 10) + if (score.MaxCombo == 0 || score.Accuracy == 0) + { + return (long)Math.Round(( + 0 + + 500000 * Math.Pow(score.Accuracy, 5) + + bonusProportion) * modMultiplier); + } + + // Assumptions: + // - sliders and slider ticks are uniformly distributed in the beatmap, and thus can be ignored without losing much precision. + // We thus consider a map of hit-circles only, which gives objectCount == maximumCombo. + // - the Ok/Meh hit results are uniformly spread in the score, and thus can be ignored without losing much precision. + // We simplify and consider each hit result to have the same hit value of `300 * score.Accuracy` + // (which represents the average hit value over the entire play), + // which allows us to isolate the accuracy multiplier. + + // This is a very ballpark estimate of the maximum magnitude of the combo portion in score V1. + // It is derived by assuming a full combo play and summing up the contribution to combo portion from each individual object. + // Because each object's combo contribution is proportional to the current combo at the time of judgement, + // this can be roughly represented by summing / integrating f(combo) = combo. + // All mod- and beatmap-dependent multipliers and constants are not included here, + // as we will only be using the magnitude of this to compute ratios. + int maximumLegacyCombo = attributes.MaxCombo; + double maximumAchievableComboPortionInScoreV1 = Math.Pow(maximumLegacyCombo, 2); + // Similarly, estimate the maximum magnitude of the combo portion in standardised score. + // Roughly corresponds to integrating f(combo) = combo ^ COMBO_EXPONENT (omitting constants) + double maximumAchievableComboPortionInStandardisedScore = Math.Pow(maximumLegacyCombo, 1 + ScoreProcessor.COMBO_EXPONENT); + + // This is - roughly - how much score, in the combo portion, the longest combo on this particular play would gain in score V1. + double comboPortionFromLongestComboInScoreV1 = Math.Pow(score.MaxCombo, 2); + // Same for standardised score. + double comboPortionFromLongestComboInStandardisedScore = Math.Pow(score.MaxCombo, 1 + ScoreProcessor.COMBO_EXPONENT); + + // We estimate the combo portion of the score in score V1 terms. + // The division by accuracy is supposed to lessen the impact of accuracy on the combo portion, + // but in some edge cases it cannot sanely undo it. + // Therefore the resultant value is clamped from both sides for sanity. + // The clamp from below to `comboPortionFromLongestComboInScoreV1` targets near-FC scores wherein + // the player had bad accuracy at the end of their longest combo, which causes the division by accuracy + // to underestimate the combo portion. + // Ideally, this would be clamped from above to `maximumAchievableComboPortionInScoreV1` too, + // but in practice this appears to fail for some scores (https://github.com/ppy/osu/pull/25876#issuecomment-1862248413). + // TODO: investigate the above more closely + double comboPortionInScoreV1 = Math.Max(maximumAchievableComboPortionInScoreV1 * comboProportion / score.Accuracy, comboPortionFromLongestComboInScoreV1); + + // Calculate how many times the longest combo the user has achieved in the play can repeat + // without exceeding the combo portion in score V1 as achieved by the player. + // This is a pessimistic estimate; it intentionally does not operate on object count and uses only score instead. + double maximumOccurrencesOfLongestCombo = Math.Floor(comboPortionInScoreV1 / comboPortionFromLongestComboInScoreV1); + double comboPortionFromRepeatedLongestCombosInScoreV1 = maximumOccurrencesOfLongestCombo * comboPortionFromLongestComboInScoreV1; + + double remainingComboPortionInScoreV1 = comboPortionInScoreV1 - comboPortionFromRepeatedLongestCombosInScoreV1; + // `remainingComboPortionInScoreV1` is in the "score ballpark" realm, which means it's proportional to combo squared. + // To convert that back to a raw combo length, we need to take the square root... + double remainingCombo = Math.Sqrt(remainingComboPortionInScoreV1); + // ...and then based on that raw combo length, we calculate how much this last combo is worth in standardised score. + double remainingComboPortionInStandardisedScore = Math.Pow(remainingCombo, 1 + ScoreProcessor.COMBO_EXPONENT); + + double lowerEstimateOfComboPortionInStandardisedScore + = maximumOccurrencesOfLongestCombo * comboPortionFromLongestComboInStandardisedScore + + remainingComboPortionInStandardisedScore; + + // Compute approximate upper estimate new score for that play. + // This time, divide the remaining combo among remaining objects equally to achieve longest possible combo lengths. + // There is no rigorous proof that doing this will yield a correct upper bound, but it seems to work out in practice. + remainingComboPortionInScoreV1 = comboPortionInScoreV1 - comboPortionFromLongestComboInScoreV1; + double remainingCountOfObjectsGivingCombo = maximumLegacyCombo - score.MaxCombo - score.Statistics.GetValueOrDefault(HitResult.Miss); + // Because we assumed all combos were equal, `remainingComboPortionInScoreV1` + // can be approximated by n * x^2, wherein n is the assumed number of equal combos, + // and x is the assumed length of every one of those combos. + // The remaining count of objects giving combo is, using those terms, equal to n * x. + // Therefore, dividing the two will result in x, i.e. the assumed length of the remaining combos. + double lengthOfRemainingCombos = remainingCountOfObjectsGivingCombo > 0 + ? remainingComboPortionInScoreV1 / remainingCountOfObjectsGivingCombo + : 0; + // In standardised scoring, each combo yields a score proportional to combo length to the power 1 + COMBO_EXPONENT. + // Using the symbols introduced above, that would be x ^ 1.5 per combo, n times (because there are n assumed equal-length combos). + // However, because `remainingCountOfObjectsGivingCombo` - using the symbols introduced above - is assumed to be equal to n * x, + // we can skip adding the 1 and just multiply by x ^ 0.5. + remainingComboPortionInStandardisedScore = remainingCountOfObjectsGivingCombo * Math.Pow(lengthOfRemainingCombos, ScoreProcessor.COMBO_EXPONENT); + + double upperEstimateOfComboPortionInStandardisedScore = comboPortionFromLongestComboInStandardisedScore + remainingComboPortionInStandardisedScore; + + // Approximate by combining lower and upper estimates. + // As the lower-estimate is very pessimistic, we use a 30/70 ratio + // and cap it with 1.2 times the middle-point to avoid overestimates. + double estimatedComboPortionInStandardisedScore = Math.Min( + 0.3 * lowerEstimateOfComboPortionInStandardisedScore + 0.7 * upperEstimateOfComboPortionInStandardisedScore, + 1.2 * (lowerEstimateOfComboPortionInStandardisedScore + upperEstimateOfComboPortionInStandardisedScore) / 2 + ); + + double newComboScoreProportion = estimatedComboPortionInStandardisedScore / maximumAchievableComboPortionInStandardisedScore; + + convertedTotalScore = (long)Math.Round(( + 500000 * newComboScoreProportion * score.Accuracy + + 500000 * Math.Pow(score.Accuracy, 5) + bonusProportion) * modMultiplier); + break; case 1: - return (long)Math.Round(( + convertedTotalScore = (long)Math.Round(( 250000 * comboProportion + 750000 * Math.Pow(score.Accuracy, 3.6) + bonusProportion) * modMultiplier); + break; case 2: - return (long)Math.Round(( - 600000 * comboProportion - + 400000 * score.Accuracy + // compare logic in `CatchScoreProcessor`. + + // this could technically be slightly incorrect in the case of stable scores. + // because large droplet misses are counted as full misses in stable scores, + // `score.MaximumStatistics.GetValueOrDefault(Great)` will be equal to the count of fruits *and* large droplets + // rather than just fruits (which was the intent). + // this is not fixable without introducing an extra legacy score attribute dedicated for catch, + // and this is a ballpark conversion process anyway, so attempt to trudge on. + int fruitTinyScaleDivisor = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + score.MaximumStatistics.GetValueOrDefault(HitResult.Great); + double fruitTinyScale = fruitTinyScaleDivisor == 0 + ? 0 + : (double)score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) / fruitTinyScaleDivisor; + + const int max_tiny_droplets_portion = 400000; + + double comboPortion = 1000000 - max_tiny_droplets_portion + max_tiny_droplets_portion * (1 - fruitTinyScale); + double dropletsPortion = max_tiny_droplets_portion * fruitTinyScale; + double dropletsHit = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) == 0 + ? 0 + : (double)score.Statistics.GetValueOrDefault(HitResult.SmallTickHit) / score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit); + + convertedTotalScore = (long)Math.Round(( + comboPortion * estimateComboProportionForCatch(attributes.MaxCombo, score.MaxCombo, score.Statistics.GetValueOrDefault(HitResult.Miss)) + + dropletsPortion * dropletsHit + bonusProportion) * modMultiplier); + break; case 3: - return (long)Math.Round(( - 990000 * comboProportion - + 10000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy) + // in the mania case accuracy actually changes between score V1 and score V2 / standardised + // (PERFECT weighting changes from 300 to 305), + // so for better accuracy recompute accuracy locally based on hit statistics and use that instead, + double scoreV2Accuracy = ComputeAccuracy(score); + + convertedTotalScore = (long)Math.Round(( + 850000 * comboProportion + + 150000 * Math.Pow(scoreV2Accuracy, 2 + 2 * scoreV2Accuracy) + bonusProportion) * modMultiplier); + break; default: - return score.TotalScore; + convertedTotalScore = score.TotalScore; + break; } + + if (convertedTotalScore < 0) + throw new InvalidOperationException($"Total score conversion operation returned invalid total of {convertedTotalScore}"); + + return convertedTotalScore; + } + + /// + /// + /// For catch, the general method of calculating the combo proportion used for other rulesets is generally useless. + /// This is because in stable score V1, catch has quadratic score progression, + /// while in stable score V2, score progression is logarithmic up to 200 combo and then linear. + /// + /// + /// This means that applying the naive rescale method to scores with lots of short combos (think 10x 100-long combos on a 1000-object map) + /// by linearly rescaling the combo portion as given by score V1 leads to horribly underestimating it. + /// Therefore this method attempts to counteract this by calculating the best case estimate for the combo proportion that takes all of the above into account. + /// + /// + /// The general idea is that aside from the which the player is known to have hit, + /// the remaining misses are evenly distributed across the rest of the objects that give combo. + /// This is therefore a worst-case estimate. + /// + /// + private static double estimateComboProportionForCatch(int beatmapMaxCombo, int scoreMaxCombo, int scoreMissCount) + { + if (beatmapMaxCombo == 0) + return 1; + + if (scoreMaxCombo == 0) + return 0; + + if (beatmapMaxCombo == scoreMaxCombo) + return 1; + + double estimatedBestCaseTotal = estimateBestCaseComboTotal(beatmapMaxCombo); + + int remainingCombo = beatmapMaxCombo - (scoreMaxCombo + scoreMissCount); + double totalDroppedScore = 0; + + int assumedLengthOfRemainingCombos = (int)Math.Floor((double)remainingCombo / scoreMissCount); + + if (assumedLengthOfRemainingCombos > 0) + { + int assumedCombosCount = (int)Math.Floor((double)remainingCombo / assumedLengthOfRemainingCombos); + totalDroppedScore += assumedCombosCount * estimateDroppedComboScoreAfterMiss(assumedLengthOfRemainingCombos); + + remainingCombo -= assumedCombosCount * assumedLengthOfRemainingCombos; + + if (remainingCombo > 0) + totalDroppedScore += estimateDroppedComboScoreAfterMiss(remainingCombo); + } + else + { + // there are so many misses that attempting to evenly divide remaining combo results in 0 length per combo, + // i.e. all remaining judgements are combo breaks. + // in that case, presume every single remaining object is a miss and did not give any combo score. + totalDroppedScore = estimatedBestCaseTotal - estimateBestCaseComboTotal(scoreMaxCombo); + } + + return estimatedBestCaseTotal == 0 + ? 1 + : 1 - Math.Clamp(totalDroppedScore / estimatedBestCaseTotal, 0, 1); + + double estimateBestCaseComboTotal(int maxCombo) + { + if (maxCombo == 0) + return 1; + + double estimatedTotal = 0.5 * Math.Min(maxCombo, 2); + + if (maxCombo <= 2) + return estimatedTotal; + + // int_2^x log_4(t) dt + estimatedTotal += (Math.Min(maxCombo, 200) * (Math.Log(Math.Min(maxCombo, 200)) - 1) + 2 - Math.Log(4)) / Math.Log(4); + + if (maxCombo <= 200) + return estimatedTotal; + + estimatedTotal += (maxCombo - 200) * Math.Log(200) / Math.Log(4); + return estimatedTotal; + } + + double estimateDroppedComboScoreAfterMiss(int lengthOfComboAfterMiss) + { + if (lengthOfComboAfterMiss >= 200) + lengthOfComboAfterMiss = 200; + + // int_0^x (log_4(200) - log_4(t)) dt + // note that this is an pessimistic estimate, i.e. it may subtract too much if the miss happened before reaching 200 combo + return lengthOfComboAfterMiss * (1 + Math.Log(200) - Math.Log(lengthOfComboAfterMiss)) / Math.Log(4); + } + } + + public static double ComputeAccuracy(ScoreInfo scoreInfo) + { + Ruleset ruleset = scoreInfo.Ruleset.CreateInstance(); + ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); + + int baseScore = scoreInfo.Statistics.Where(kvp => kvp.Key.AffectsAccuracy()) + .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); + int maxBaseScore = scoreInfo.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy()) + .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); + + return maxBaseScore == 0 ? 1 : baseScore / (double)maxBaseScore; } /// diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 0ee42c69d5..1a78c1ec5e 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -286,7 +286,7 @@ namespace osu.Game.Graphics.Backgrounds private IUniformBuffer borderDataBuffer; - public override void Draw(IRenderer renderer) + protected override void Draw(IRenderer renderer) { base.Draw(renderer); diff --git a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs index 750e96440d..a20fd78569 100644 --- a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs +++ b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs @@ -228,7 +228,7 @@ namespace osu.Game.Graphics.Backgrounds private IUniformBuffer? borderDataBuffer; - public override void Draw(IRenderer renderer) + protected override void Draw(IRenderer renderer) { base.Draw(renderer); diff --git a/osu.Game/Graphics/Containers/ExpandingButtonContainer.cs b/osu.Game/Graphics/Containers/ExpandingButtonContainer.cs deleted file mode 100644 index 5abb4096ac..0000000000 --- a/osu.Game/Graphics/Containers/ExpandingButtonContainer.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Graphics.Containers -{ - /// - /// An with a long hover expansion delay. - /// - /// - /// Mostly used for buttons with explanatory labels, in which the label would display after a "long hover". - /// - public partial class ExpandingButtonContainer : ExpandingContainer - { - protected ExpandingButtonContainer(float contractedWidth, float expandedWidth) - : base(contractedWidth, expandedWidth) - { - } - - protected override double HoverExpansionDelay => 400; - } -} diff --git a/osu.Game/Graphics/Containers/ExpandingContainer.cs b/osu.Game/Graphics/Containers/ExpandingContainer.cs index 60b9e6a167..2abdb508ae 100644 --- a/osu.Game/Graphics/Containers/ExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -26,6 +24,8 @@ namespace osu.Game.Graphics.Containers /// protected virtual double HoverExpansionDelay => 0; + protected virtual bool ExpandOnHover => true; + protected override Container Content => FillFlow; protected FillFlowContainer FillFlow { get; } @@ -53,7 +53,7 @@ namespace osu.Game.Graphics.Containers }; } - private ScheduledDelegate hoverExpandEvent; + private ScheduledDelegate? hoverExpandEvent; protected override void LoadComplete() { @@ -93,6 +93,9 @@ namespace osu.Game.Graphics.Containers private void updateHoverExpansion() { + if (!ExpandOnHover) + return; + hoverExpandEvent?.Cancel(); if (IsHovered && !Expanded.Value) diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index da6996c170..124becc35a 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -44,9 +44,6 @@ namespace osu.Game.Graphics.Containers private bool shouldPerformRightMouseScroll(MouseButtonEvent e) => RightMouseScrollbar && e.Button == MouseButton.Right; - private void scrollFromMouseEvent(MouseEvent e) => - ScrollTo(Clamp(ToLocalSpace(e.ScreenSpaceMousePosition)[ScrollDim] / DrawSize[ScrollDim]) * Content.DrawSize[ScrollDim], true, DistanceDecayOnRightMouseScrollbar); - private bool rightMouseDragging; protected override bool IsDragging => base.IsDragging || rightMouseDragging; @@ -80,7 +77,7 @@ namespace osu.Game.Graphics.Containers { if (shouldPerformRightMouseScroll(e)) { - scrollFromMouseEvent(e); + ScrollFromMouseEvent(e); return true; } @@ -91,7 +88,7 @@ namespace osu.Game.Graphics.Containers { if (rightMouseDragging) { - scrollFromMouseEvent(e); + ScrollFromMouseEvent(e); return; } @@ -129,6 +126,9 @@ namespace osu.Game.Graphics.Containers return base.OnScroll(e); } + protected virtual void ScrollFromMouseEvent(MouseEvent e) => + ScrollTo(Clamp(ToLocalSpace(e.ScreenSpaceMousePosition)[ScrollDim] / DrawSize[ScrollDim]) * Content.DrawSize[ScrollDim], true, DistanceDecayOnRightMouseScrollbar); + protected override ScrollbarContainer CreateScrollbar(Direction direction) => new OsuScrollbar(direction); protected partial class OsuScrollbar : ScrollbarContainer diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs index 6934c95385..354a57b7d2 100644 --- a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs +++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Input.Events; namespace osu.Game.Graphics.Containers { @@ -46,6 +47,12 @@ namespace osu.Game.Graphics.Containers base.ScrollIntoView(target, animated); } + protected override void ScrollFromMouseEvent(MouseEvent e) + { + UserScrolling = true; + base.ScrollFromMouseEvent(e); + } + public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) { UserScrolling = false; diff --git a/osu.Game/Graphics/HexaconsIcons.cs b/osu.Game/Graphics/HexaconsIcons.cs deleted file mode 100644 index 3eee5d7197..0000000000 --- a/osu.Game/Graphics/HexaconsIcons.cs +++ /dev/null @@ -1,131 +0,0 @@ -// 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.IO; -using System.Threading; -using System.Threading.Tasks; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Framework.Text; - -namespace osu.Game.Graphics -{ - public static class HexaconsIcons - { - public const string FONT_NAME = "Icons/Hexacons"; - - public static IconUsage BeatmapPacks => get(HexaconsMapping.beatmap_packs); - public static IconUsage Beatmap => get(HexaconsMapping.beatmap); - public static IconUsage Calendar => get(HexaconsMapping.calendar); - public static IconUsage Chart => get(HexaconsMapping.chart); - public static IconUsage Community => get(HexaconsMapping.community); - public static IconUsage Contests => get(HexaconsMapping.contests); - public static IconUsage Devtools => get(HexaconsMapping.devtools); - public static IconUsage Download => get(HexaconsMapping.download); - public static IconUsage Editor => get(HexaconsMapping.editor); - public static IconUsage FeaturedArtist => get(HexaconsMapping.featured_artist); - public static IconUsage Home => get(HexaconsMapping.home); - public static IconUsage Messaging => get(HexaconsMapping.messaging); - public static IconUsage Music => get(HexaconsMapping.music); - public static IconUsage News => get(HexaconsMapping.news); - public static IconUsage Notification => get(HexaconsMapping.notification); - public static IconUsage Profile => get(HexaconsMapping.profile); - public static IconUsage Rankings => get(HexaconsMapping.rankings); - public static IconUsage Search => get(HexaconsMapping.search); - public static IconUsage Settings => get(HexaconsMapping.settings); - public static IconUsage Social => get(HexaconsMapping.social); - public static IconUsage Store => get(HexaconsMapping.store); - public static IconUsage Tournament => get(HexaconsMapping.tournament); - public static IconUsage Wiki => get(HexaconsMapping.wiki); - - private static IconUsage get(HexaconsMapping icon) => new IconUsage((char)icon, FONT_NAME); - - // Basically just converting to something we can use in a `char` lookup for FontStore/GlyphStore compatibility. - // Names should match filenames in resources. - private enum HexaconsMapping - { - beatmap_packs, - beatmap, - calendar, - chart, - community, - contests, - devtools, - download, - editor, - featured_artist, - home, - messaging, - music, - news, - notification, - profile, - rankings, - search, - settings, - social, - store, - tournament, - wiki, - } - - public class HexaconsStore : ITextureStore, ITexturedGlyphLookupStore - { - private readonly TextureStore textures; - - public HexaconsStore(TextureStore textures) - { - this.textures = textures; - } - - public void Dispose() - { - textures.Dispose(); - } - - public ITexturedCharacterGlyph? Get(string? fontName, char character) - { - if (fontName == FONT_NAME) - return new Glyph(textures.Get($"{fontName}/{((HexaconsMapping)character).ToString().Replace("_", "-")}")); - - return null; - } - - public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); - - public Texture? Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => null; - - public Texture Get(string name) => throw new NotImplementedException(); - - public Task GetAsync(string name, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - public Stream GetStream(string name) => throw new NotImplementedException(); - - public IEnumerable GetAvailableResources() => throw new NotImplementedException(); - - public Task GetAsync(string name, WrapMode wrapModeS, WrapMode wrapModeT, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - public class Glyph : ITexturedCharacterGlyph - { - public float XOffset => default; - public float YOffset => default; - public float XAdvance => default; - public float Baseline => default; - public char Character => default; - - public float GetKerning(T lastGlyph) where T : ICharacterGlyph => throw new NotImplementedException(); - - public Texture Texture { get; } - public float Width => Texture.Width; - public float Height => Texture.Height; - - public Glyph(Texture texture) - { - Texture = texture; - } - } - } - } -} diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index a417164e27..0d11d2d4ef 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -75,9 +75,12 @@ namespace osu.Game.Graphics { switch (result) { + case HitResult.IgnoreMiss: case HitResult.SmallTickMiss: - case HitResult.LargeTickMiss: + return Orange1; + case HitResult.Miss: + case HitResult.LargeTickMiss: case HitResult.ComboBreak: return Red; diff --git a/osu.Game/Graphics/OsuIcon.cs b/osu.Game/Graphics/OsuIcon.cs index 15af8f000b..3cd10b1315 100644 --- a/osu.Game/Graphics/OsuIcon.cs +++ b/osu.Game/Graphics/OsuIcon.cs @@ -1,96 +1,444 @@ // 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.ComponentModel; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Extensions; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Text; namespace osu.Game.Graphics { public static class OsuIcon { - public static IconUsage Get(int icon) => new IconUsage((char)icon, "osuFont"); + #region Legacy spritesheet-based icons + + private static IconUsage get(int icon) => new IconUsage((char)icon, @"osuFont"); // ruleset icons in circles - public static IconUsage RulesetOsu => Get(0xe000); - public static IconUsage RulesetMania => Get(0xe001); - public static IconUsage RulesetCatch => Get(0xe002); - public static IconUsage RulesetTaiko => Get(0xe003); + public static IconUsage RulesetOsu => get(0xe000); + public static IconUsage RulesetMania => get(0xe001); + public static IconUsage RulesetCatch => get(0xe002); + public static IconUsage RulesetTaiko => get(0xe003); // ruleset icons without circles - public static IconUsage FilledCircle => Get(0xe004); - public static IconUsage CrossCircle => Get(0xe005); - public static IconUsage Logo => Get(0xe006); - public static IconUsage ChevronDownCircle => Get(0xe007); - public static IconUsage EditCircle => Get(0xe033); - public static IconUsage LeftCircle => Get(0xe034); - public static IconUsage RightCircle => Get(0xe035); - public static IconUsage Charts => Get(0xe036); - public static IconUsage Solo => Get(0xe037); - public static IconUsage Multi => Get(0xe038); - public static IconUsage Gear => Get(0xe039); + public static IconUsage FilledCircle => get(0xe004); + public static IconUsage Logo => get(0xe006); + public static IconUsage ChevronDownCircle => get(0xe007); + public static IconUsage EditCircle => get(0xe033); + public static IconUsage LeftCircle => get(0xe034); + public static IconUsage RightCircle => get(0xe035); + public static IconUsage Charts => get(0xe036); + public static IconUsage Solo => get(0xe037); + public static IconUsage Multi => get(0xe038); + public static IconUsage Gear => get(0xe039); // misc icons - public static IconUsage Bat => Get(0xe008); - public static IconUsage Bubble => Get(0xe009); - public static IconUsage BubblePop => Get(0xe02e); - public static IconUsage Dice => Get(0xe011); - public static IconUsage Heart => Get(0xe02f); - public static IconUsage HeartBreak => Get(0xe030); - public static IconUsage Hot => Get(0xe031); - public static IconUsage ListSearch => Get(0xe032); + public static IconUsage Bat => get(0xe008); + public static IconUsage Bubble => get(0xe009); + public static IconUsage BubblePop => get(0xe02e); + public static IconUsage Dice => get(0xe011); + public static IconUsage HeartBreak => get(0xe030); + public static IconUsage Hot => get(0xe031); + public static IconUsage ListSearch => get(0xe032); //osu! playstyles - public static IconUsage PlayStyleTablet => Get(0xe02a); - public static IconUsage PlayStyleMouse => Get(0xe029); - public static IconUsage PlayStyleKeyboard => Get(0xe02b); - public static IconUsage PlayStyleTouch => Get(0xe02c); + public static IconUsage PlayStyleTablet => get(0xe02a); + public static IconUsage PlayStyleMouse => get(0xe029); + public static IconUsage PlayStyleKeyboard => get(0xe02b); + public static IconUsage PlayStyleTouch => get(0xe02c); // osu! difficulties - public static IconUsage EasyOsu => Get(0xe015); - public static IconUsage NormalOsu => Get(0xe016); - public static IconUsage HardOsu => Get(0xe017); - public static IconUsage InsaneOsu => Get(0xe018); - public static IconUsage ExpertOsu => Get(0xe019); + public static IconUsage EasyOsu => get(0xe015); + public static IconUsage NormalOsu => get(0xe016); + public static IconUsage HardOsu => get(0xe017); + public static IconUsage InsaneOsu => get(0xe018); + public static IconUsage ExpertOsu => get(0xe019); // taiko difficulties - public static IconUsage EasyTaiko => Get(0xe01a); - public static IconUsage NormalTaiko => Get(0xe01b); - public static IconUsage HardTaiko => Get(0xe01c); - public static IconUsage InsaneTaiko => Get(0xe01d); - public static IconUsage ExpertTaiko => Get(0xe01e); + public static IconUsage EasyTaiko => get(0xe01a); + public static IconUsage NormalTaiko => get(0xe01b); + public static IconUsage HardTaiko => get(0xe01c); + public static IconUsage InsaneTaiko => get(0xe01d); + public static IconUsage ExpertTaiko => get(0xe01e); // fruits difficulties - public static IconUsage EasyFruits => Get(0xe01f); - public static IconUsage NormalFruits => Get(0xe020); - public static IconUsage HardFruits => Get(0xe021); - public static IconUsage InsaneFruits => Get(0xe022); - public static IconUsage ExpertFruits => Get(0xe023); + public static IconUsage EasyFruits => get(0xe01f); + public static IconUsage NormalFruits => get(0xe020); + public static IconUsage HardFruits => get(0xe021); + public static IconUsage InsaneFruits => get(0xe022); + public static IconUsage ExpertFruits => get(0xe023); // mania difficulties - public static IconUsage EasyMania => Get(0xe024); - public static IconUsage NormalMania => Get(0xe025); - public static IconUsage HardMania => Get(0xe026); - public static IconUsage InsaneMania => Get(0xe027); - public static IconUsage ExpertMania => Get(0xe028); + public static IconUsage EasyMania => get(0xe024); + public static IconUsage NormalMania => get(0xe025); + public static IconUsage HardMania => get(0xe026); + public static IconUsage InsaneMania => get(0xe027); + public static IconUsage ExpertMania => get(0xe028); // mod icons - public static IconUsage ModPerfect => Get(0xe049); - public static IconUsage ModAutopilot => Get(0xe03a); - public static IconUsage ModAuto => Get(0xe03b); - public static IconUsage ModCinema => Get(0xe03c); - public static IconUsage ModDoubleTime => Get(0xe03d); - public static IconUsage ModEasy => Get(0xe03e); - public static IconUsage ModFlashlight => Get(0xe03f); - public static IconUsage ModHalftime => Get(0xe040); - public static IconUsage ModHardRock => Get(0xe041); - public static IconUsage ModHidden => Get(0xe042); - public static IconUsage ModNightcore => Get(0xe043); - public static IconUsage ModNoFail => Get(0xe044); - public static IconUsage ModRelax => Get(0xe045); - public static IconUsage ModSpunOut => Get(0xe046); - public static IconUsage ModSuddenDeath => Get(0xe047); - public static IconUsage ModTarget => Get(0xe048); + public static IconUsage ModPerfect => get(0xe049); + public static IconUsage ModAutopilot => get(0xe03a); + public static IconUsage ModAuto => get(0xe03b); + public static IconUsage ModCinema => get(0xe03c); + public static IconUsage ModDoubleTime => get(0xe03d); + public static IconUsage ModEasy => get(0xe03e); + public static IconUsage ModFlashlight => get(0xe03f); + public static IconUsage ModHalftime => get(0xe040); + public static IconUsage ModHardRock => get(0xe041); + public static IconUsage ModHidden => get(0xe042); + public static IconUsage ModNightcore => get(0xe043); + public static IconUsage ModNoFail => get(0xe044); + public static IconUsage ModRelax => get(0xe045); + public static IconUsage ModSpunOut => get(0xe046); + public static IconUsage ModSuddenDeath => get(0xe047); + public static IconUsage ModTarget => get(0xe048); // Use "Icons/BeatmapDetails/mod-icon" instead // public static IconUsage ModBg => Get(0xe04a); + + #endregion + + #region New single-file-based icons + + public const string FONT_NAME = @"Icons"; + + public static IconUsage Audio => get(OsuIconMapping.Audio); + public static IconUsage Beatmap => get(OsuIconMapping.Beatmap); + public static IconUsage Calendar => get(OsuIconMapping.Calendar); + public static IconUsage ChangelogA => get(OsuIconMapping.ChangelogA); + public static IconUsage ChangelogB => get(OsuIconMapping.ChangelogB); + public static IconUsage Chat => get(OsuIconMapping.Chat); + public static IconUsage CheckCircle => get(OsuIconMapping.CheckCircle); + public static IconUsage CollapseA => get(OsuIconMapping.CollapseA); + public static IconUsage Collections => get(OsuIconMapping.Collections); + public static IconUsage Cross => get(OsuIconMapping.Cross); + public static IconUsage CrossCircle => get(OsuIconMapping.CrossCircle); + public static IconUsage Crown => get(OsuIconMapping.Crown); + public static IconUsage Debug => get(OsuIconMapping.Debug); + public static IconUsage Delete => get(OsuIconMapping.Delete); + public static IconUsage Details => get(OsuIconMapping.Details); + public static IconUsage Discord => get(OsuIconMapping.Discord); + public static IconUsage EllipsisHorizontal => get(OsuIconMapping.EllipsisHorizontal); + public static IconUsage EllipsisVertical => get(OsuIconMapping.EllipsisVertical); + public static IconUsage ExpandA => get(OsuIconMapping.ExpandA); + public static IconUsage ExpandB => get(OsuIconMapping.ExpandB); + public static IconUsage FeaturedArtist => get(OsuIconMapping.FeaturedArtist); + public static IconUsage FeaturedArtistCircle => get(OsuIconMapping.FeaturedArtistCircle); + public static IconUsage GameplayA => get(OsuIconMapping.GameplayA); + public static IconUsage GameplayB => get(OsuIconMapping.GameplayB); + public static IconUsage GameplayC => get(OsuIconMapping.GameplayC); + public static IconUsage Global => get(OsuIconMapping.Global); + public static IconUsage Graphics => get(OsuIconMapping.Graphics); + public static IconUsage Heart => get(OsuIconMapping.Heart); + public static IconUsage Home => get(OsuIconMapping.Home); + public static IconUsage Input => get(OsuIconMapping.Input); + public static IconUsage Maintenance => get(OsuIconMapping.Maintenance); + public static IconUsage Megaphone => get(OsuIconMapping.Megaphone); + public static IconUsage Music => get(OsuIconMapping.Music); + public static IconUsage News => get(OsuIconMapping.News); + public static IconUsage Next => get(OsuIconMapping.Next); + public static IconUsage NextCircle => get(OsuIconMapping.NextCircle); + public static IconUsage Notification => get(OsuIconMapping.Notification); + public static IconUsage Online => get(OsuIconMapping.Online); + public static IconUsage Play => get(OsuIconMapping.Play); + public static IconUsage Player => get(OsuIconMapping.Player); + public static IconUsage PlayerFollow => get(OsuIconMapping.PlayerFollow); + public static IconUsage Prev => get(OsuIconMapping.Prev); + public static IconUsage PrevCircle => get(OsuIconMapping.PrevCircle); + public static IconUsage Ranking => get(OsuIconMapping.Ranking); + public static IconUsage Rulesets => get(OsuIconMapping.Rulesets); + public static IconUsage Search => get(OsuIconMapping.Search); + public static IconUsage Settings => get(OsuIconMapping.Settings); + public static IconUsage SkinA => get(OsuIconMapping.SkinA); + public static IconUsage SkinB => get(OsuIconMapping.SkinB); + public static IconUsage Star => get(OsuIconMapping.Star); + public static IconUsage Storyboard => get(OsuIconMapping.Storyboard); + public static IconUsage Team => get(OsuIconMapping.Team); + public static IconUsage ThumbsUp => get(OsuIconMapping.ThumbsUp); + public static IconUsage Tournament => get(OsuIconMapping.Tournament); + public static IconUsage Twitter => get(OsuIconMapping.Twitter); + public static IconUsage UserInterface => get(OsuIconMapping.UserInterface); + public static IconUsage Wiki => get(OsuIconMapping.Wiki); + public static IconUsage EditorAddControlPoint => get(OsuIconMapping.EditorAddControlPoint); + public static IconUsage EditorConvertToStream => get(OsuIconMapping.EditorConvertToStream); + public static IconUsage EditorDistanceSnap => get(OsuIconMapping.EditorDistanceSnap); + public static IconUsage EditorFinish => get(OsuIconMapping.EditorFinish); + public static IconUsage EditorGridSnap => get(OsuIconMapping.EditorGridSnap); + public static IconUsage EditorNewComboA => get(OsuIconMapping.EditorNewComboA); + public static IconUsage EditorNewComboB => get(OsuIconMapping.EditorNewComboB); + public static IconUsage EditorSelect => get(OsuIconMapping.EditorSelect); + public static IconUsage EditorSound => get(OsuIconMapping.EditorSound); + public static IconUsage EditorWhistle => get(OsuIconMapping.EditorWhistle); + + private static IconUsage get(OsuIconMapping glyph) => new IconUsage((char)glyph, FONT_NAME); + + private enum OsuIconMapping + { + [Description(@"audio")] + Audio, + + [Description(@"beatmap")] + Beatmap, + + [Description(@"calendar")] + Calendar, + + [Description(@"changelog-a")] + ChangelogA, + + [Description(@"changelog-b")] + ChangelogB, + + [Description(@"chat")] + Chat, + + [Description(@"check-circle")] + CheckCircle, + + [Description(@"collapse-a")] + CollapseA, + + [Description(@"collections")] + Collections, + + [Description(@"cross")] + Cross, + + [Description(@"cross-circle")] + CrossCircle, + + [Description(@"crown")] + Crown, + + [Description(@"debug")] + Debug, + + [Description(@"delete")] + Delete, + + [Description(@"details")] + Details, + + [Description(@"discord")] + Discord, + + [Description(@"ellipsis-horizontal")] + EllipsisHorizontal, + + [Description(@"ellipsis-vertical")] + EllipsisVertical, + + [Description(@"expand-a")] + ExpandA, + + [Description(@"expand-b")] + ExpandB, + + [Description(@"featured-artist")] + FeaturedArtist, + + [Description(@"featured-artist-circle")] + FeaturedArtistCircle, + + [Description(@"gameplay-a")] + GameplayA, + + [Description(@"gameplay-b")] + GameplayB, + + [Description(@"gameplay-c")] + GameplayC, + + [Description(@"global")] + Global, + + [Description(@"graphics")] + Graphics, + + [Description(@"heart")] + Heart, + + [Description(@"home")] + Home, + + [Description(@"input")] + Input, + + [Description(@"maintenance")] + Maintenance, + + [Description(@"megaphone")] + Megaphone, + + [Description(@"music")] + Music, + + [Description(@"news")] + News, + + [Description(@"next")] + Next, + + [Description(@"next-circle")] + NextCircle, + + [Description(@"notification")] + Notification, + + [Description(@"online")] + Online, + + [Description(@"play")] + Play, + + [Description(@"player")] + Player, + + [Description(@"player-follow")] + PlayerFollow, + + [Description(@"prev")] + Prev, + + [Description(@"prev-circle")] + PrevCircle, + + [Description(@"ranking")] + Ranking, + + [Description(@"rulesets")] + Rulesets, + + [Description(@"search")] + Search, + + [Description(@"settings")] + Settings, + + [Description(@"skin-a")] + SkinA, + + [Description(@"skin-b")] + SkinB, + + [Description(@"star")] + Star, + + [Description(@"storyboard")] + Storyboard, + + [Description(@"team")] + Team, + + [Description(@"thumbs-up")] + ThumbsUp, + + [Description(@"tournament")] + Tournament, + + [Description(@"twitter")] + Twitter, + + [Description(@"user-interface")] + UserInterface, + + [Description(@"wiki")] + Wiki, + + [Description(@"Editor/add-control-point")] + EditorAddControlPoint = 1000, + + [Description(@"Editor/convert-to-stream")] + EditorConvertToStream, + + [Description(@"Editor/distance-snap")] + EditorDistanceSnap, + + [Description(@"Editor/finish")] + EditorFinish, + + [Description(@"Editor/grid-snap")] + EditorGridSnap, + + [Description(@"Editor/new-combo-a")] + EditorNewComboA, + + [Description(@"Editor/new-combo-b")] + EditorNewComboB, + + [Description(@"Editor/select")] + EditorSelect, + + [Description(@"Editor/sound")] + EditorSound, + + [Description(@"Editor/whistle")] + EditorWhistle, + } + + public class OsuIconStore : ITextureStore, ITexturedGlyphLookupStore + { + private readonly TextureStore textures; + + public OsuIconStore(TextureStore textures) + { + this.textures = textures; + } + + public ITexturedCharacterGlyph? Get(string? fontName, char character) + { + if (fontName == FONT_NAME) + return new Glyph(textures.Get($@"{fontName}/{((OsuIconMapping)character).GetDescription()}")); + + return null; + } + + public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); + + public Texture? Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => null; + + public Texture Get(string name) => throw new NotImplementedException(); + + public Task GetAsync(string name, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + public Stream GetStream(string name) => throw new NotImplementedException(); + + public IEnumerable GetAvailableResources() => throw new NotImplementedException(); + + public Task GetAsync(string name, WrapMode wrapModeS, WrapMode wrapModeT, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + public class Glyph : ITexturedCharacterGlyph + { + public float XOffset => default; + public float YOffset => default; + public float XAdvance => default; + public float Baseline => default; + public char Character => default; + + public float GetKerning(T lastGlyph) where T : ICharacterGlyph => throw new NotImplementedException(); + + public Texture Texture { get; } + public float Width => Texture.Width; + public float Height => Texture.Height; + + public Glyph(Texture texture) + { + Texture = texture; + } + } + + public void Dispose() + { + textures.Dispose(); + } + } + + #endregion } } diff --git a/osu.Game/Graphics/UserInterface/BarGraph.cs b/osu.Game/Graphics/UserInterface/BarGraph.cs index 0ac987e85b..e7592128b0 100644 --- a/osu.Game/Graphics/UserInterface/BarGraph.cs +++ b/osu.Game/Graphics/UserInterface/BarGraph.cs @@ -134,7 +134,7 @@ namespace osu.Game.Graphics.UserInterface lengths.AddRange(Source.bars.InstantaneousLengths); } - public override void Draw(IRenderer renderer) + protected override void Draw(IRenderer renderer) { base.Draw(renderer); diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index db81bc991d..c920597a95 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -25,7 +25,7 @@ namespace osu.Game.Graphics.UserInterface private const float idle_width = 0.8f; private const float hover_width = 0.9f; - private const float hover_duration = 500; + private const float hover_duration = 300; private const float click_duration = 200; public event Action? StateChanged; @@ -54,7 +54,7 @@ namespace osu.Game.Graphics.UserInterface private readonly Box rightGlow; private readonly Box background; private readonly SpriteText spriteText; - private Vector2 hoverSpacing => new Vector2(3f, 0f); + private Vector2 hoverSpacing => new Vector2(1.4f, 0f); public DialogButton(HoverSampleSet sampleSet = HoverSampleSet.Button) : base(sampleSet) @@ -279,15 +279,15 @@ namespace osu.Game.Graphics.UserInterface if (newState == SelectionState.Selected) { - spriteText.TransformSpacingTo(hoverSpacing, hover_duration, Easing.OutElastic); - ColourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutElastic); + spriteText.TransformSpacingTo(hoverSpacing, hover_duration, Easing.OutQuint); + ColourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutQuint); glowContainer.FadeIn(hover_duration, Easing.OutQuint); } else { - ColourContainer.ResizeWidthTo(idle_width, hover_duration, Easing.OutElastic); - spriteText.TransformSpacingTo(Vector2.Zero, hover_duration, Easing.OutElastic); - glowContainer.FadeOut(hover_duration, Easing.OutQuint); + ColourContainer.ResizeWidthTo(idle_width, hover_duration / 2, Easing.OutQuint); + spriteText.TransformSpacingTo(Vector2.Zero, hover_duration / 2, Easing.OutQuint); + glowContainer.FadeOut(hover_duration / 2, Easing.OutQuint); } } diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index b530172f3e..2dc701dc9d 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -22,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public partial class OsuDropdown : Dropdown + public partial class OsuDropdown : Dropdown, IKeyBindingHandler { private const float corner_radius = 5; @@ -30,9 +30,23 @@ namespace osu.Game.Graphics.UserInterface protected override DropdownMenu CreateMenu() => new OsuDropdownMenu(); + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) return false; + + if (e.Action == GlobalAction.Back) + return Back(); + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + #region OsuDropdownMenu - protected partial class OsuDropdownMenu : DropdownMenu, IKeyBindingHandler + protected partial class OsuDropdownMenu : DropdownMenu { public override bool HandleNonPositionalInput => State == MenuState.Open; @@ -276,23 +290,6 @@ namespace osu.Game.Graphics.UserInterface } #endregion - - public bool OnPressed(KeyBindingPressEvent e) - { - if (e.Repeat) return false; - - if (e.Action == GlobalAction.Back) - { - State = MenuState.Closed; - return true; - } - - return false; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - } } #endregion @@ -355,11 +352,81 @@ namespace osu.Game.Graphics.UserInterface AddInternal(new HoverClickSounds()); } - [BackgroundDependencyLoader(true)] - private void load(OverlayColourProvider? colourProvider, OsuColour colours) + [Resolved] + private OverlayColourProvider? colourProvider { get; set; } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + protected override void LoadComplete() { - BackgroundColour = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); - BackgroundColourHover = colourProvider?.Light4 ?? colours.PinkDarker; + base.LoadComplete(); + + SearchBar.State.ValueChanged += _ => updateColour(); + Enabled.BindValueChanged(_ => updateColour()); + updateColour(); + } + + protected override bool OnHover(HoverEvent e) + { + updateColour(); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateColour(); + } + + private void updateColour() + { + bool hovered = Enabled.Value && IsHovered; + var hoveredColour = colourProvider?.Light4 ?? colours.PinkDarker; + var unhoveredColour = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); + + Colour = Color4.White; + Alpha = Enabled.Value ? 1 : 0.3f; + + if (SearchBar.State.Value == Visibility.Visible) + { + Icon.Colour = hovered ? hoveredColour.Lighten(0.5f) : Colour4.White; + Background.Colour = unhoveredColour; + } + else + { + Icon.Colour = Color4.White; + Background.Colour = hovered ? hoveredColour : unhoveredColour; + } + } + + protected override DropdownSearchBar CreateSearchBar() => new OsuDropdownSearchBar + { + Padding = new MarginPadding { Right = 26 }, + }; + + private partial class OsuDropdownSearchBar : DropdownSearchBar + { + protected override void PopIn() => this.FadeIn(); + + protected override void PopOut() => this.FadeOut(); + + protected override TextBox CreateTextBox() => new DropdownSearchTextBox + { + FontSize = OsuFont.Default.Size, + }; + + private partial class DropdownSearchTextBox : SearchTextBox + { + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Back) + // this method is blocking Dropdown from receiving the back action, despite this text box residing in a separate input manager. + // to fix this properly, a local global action container needs to be added as well, but for simplicity, just don't handle the back action here. + return false; + + return base.OnPressed(e); + } + } } } } diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index 123854a2dd..0be7b4dc48 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -23,7 +23,7 @@ namespace osu.Game.Graphics.UserInterface protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer { AutoSizeAxes = Axes.Both, - Child = new PasswordMaskChar(CalculatedTextSize), + Child = new PasswordMaskChar(FontSize), }; protected override bool AllowUniqueCharacterSamples => false; diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 04ecfa7e9a..08d38837f6 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -268,7 +268,7 @@ namespace osu.Game.Graphics.UserInterface protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer { AutoSizeAxes = Axes.Both, - Child = new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: CalculatedTextSize) }, + Child = new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: FontSize) }, }; protected override Caret CreateCaret() => caret = new OsuCaret @@ -314,18 +314,16 @@ namespace osu.Game.Graphics.UserInterface public OsuCaret() { - RelativeSizeAxes = Axes.Y; - Size = new Vector2(1, 0.9f); - Colour = Color4.Transparent; - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - Masking = true; - CornerRadius = 1; InternalChild = beatSync = new CaretBeatSyncedContainer { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Masking = true, + CornerRadius = 1f, RelativeSizeAxes = Axes.Both, + Height = 0.9f, }; } diff --git a/osu.Game/Graphics/UserInterface/SegmentedGraph.cs b/osu.Game/Graphics/UserInterface/SegmentedGraph.cs index 91971e5af9..9f467687a4 100644 --- a/osu.Game/Graphics/UserInterface/SegmentedGraph.cs +++ b/osu.Game/Graphics/UserInterface/SegmentedGraph.cs @@ -221,7 +221,7 @@ namespace osu.Game.Graphics.UserInterface tierColours.AddRange(Source.tierColours); } - public override void Draw(IRenderer renderer) + protected override void Draw(IRenderer renderer) { base.Draw(renderer); diff --git a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs index 3940bf8bca..131041e706 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs @@ -110,7 +110,7 @@ namespace osu.Game.Graphics.UserInterface BackgroundFocused = colourProvider.Background4; BackgroundUnfocused = colourProvider.Background4; - Placeholder.Font = OsuFont.GetFont(size: CalculatedTextSize, weight: FontWeight.SemiBold); + Placeholder.Font = OsuFont.GetFont(size: FontSize, weight: FontWeight.SemiBold); PlaceholderText = CommonStrings.InputSearch; CornerRadius = corner_radius; diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs index 37ea2a3f96..e5ba7f61bf 100644 --- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs +++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs @@ -121,7 +121,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 break; default: - slider.Current.Parse(textBox.Current.Value); + slider.Current.Parse(textBox.Current.Value, CultureInfo.CurrentCulture); break; } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 947cd5f54f..5a39c02185 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -160,6 +160,8 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.Enter, GlobalAction.ToggleChatFocus), new KeyBinding(InputKey.F1, GlobalAction.SaveReplay), new KeyBinding(InputKey.F2, GlobalAction.ExportReplay), + new KeyBinding(InputKey.Plus, GlobalAction.IncreaseOffset), + new KeyBinding(InputKey.Minus, GlobalAction.DecreaseOffset), }; private static IEnumerable replayKeyBindings => new[] @@ -404,6 +406,12 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleRotateControl))] EditorToggleRotateControl, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.IncreaseOffset))] + IncreaseOffset, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.DecreaseOffset))] + DecreaseOffset } public enum GlobalActionCategory diff --git a/osu.Game/Localisation/ContextMenuStrings.cs b/osu.Game/Localisation/ContextMenuStrings.cs index 029fba67d8..cb18a2159c 100644 --- a/osu.Game/Localisation/ContextMenuStrings.cs +++ b/osu.Game/Localisation/ContextMenuStrings.cs @@ -20,9 +20,14 @@ namespace osu.Game.Localisation public static LocalisableString ViewBeatmap => new TranslatableString(getKey(@"view_beatmap"), @"View beatmap"); /// - /// "Invite player" + /// "Invite to room" /// - public static LocalisableString InvitePlayer => new TranslatableString(getKey(@"invite_player"), @"Invite player"); + public static LocalisableString InvitePlayer => new TranslatableString(getKey(@"invite_player"), @"Invite to room"); + + /// + /// "Spectate" + /// + public static LocalisableString SpectatePlayer => new TranslatableString(getKey(@"spectate_player"), @"Spectate"); private static string getKey(string key) => $@"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index ebf57d8109..42623f4632 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -49,6 +49,11 @@ namespace osu.Game.Localisation /// public static LocalisableString OpenOsuFolder => new TranslatableString(getKey(@"open_osu_folder"), @"Open osu! folder"); + /// + /// "Export logs" + /// + public static LocalisableString ExportLogs => new TranslatableString(getKey(@"export_logs"), @"Export logs"); + /// /// "Change folder location..." /// diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 8356c480dd..ca27d0ff95 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -344,6 +344,16 @@ namespace osu.Game.Localisation /// public static LocalisableString ExportReplay => new TranslatableString(getKey(@"export_replay"), @"Export replay"); + /// + /// "Increase offset" + /// + public static LocalisableString IncreaseOffset => new TranslatableString(getKey(@"increase_offset"), @"Increase offset"); + + /// + /// "Decrease offset" + /// + public static LocalisableString DecreaseOffset => new TranslatableString(getKey(@"decrease_offset"), @"Decrease offset"); + /// /// "Toggle rotate control" /// diff --git a/osu.Game/Localisation/NamedOverlayComponentStrings.cs b/osu.Game/Localisation/NamedOverlayComponentStrings.cs index 475bea2a4a..72e63d699a 100644 --- a/osu.Game/Localisation/NamedOverlayComponentStrings.cs +++ b/osu.Game/Localisation/NamedOverlayComponentStrings.cs @@ -20,12 +20,12 @@ namespace osu.Game.Localisation public static LocalisableString ChangelogDescription => new TranslatableString(getKey(@"changelog_description"), @"track recent dev updates in the osu! ecosystem"); /// - /// "view your friends and other information" + /// "view your friends and spectate other players" /// - public static LocalisableString DashboardDescription => new TranslatableString(getKey(@"dashboard_description"), @"view your friends and other information"); + public static LocalisableString DashboardDescription => new TranslatableString(getKey(@"dashboard_description"), @"view your friends and spectate other players"); /// - /// "find out who's the best right now" + /// "find out who's the best right now" /// public static LocalisableString RankingsDescription => new TranslatableString(getKey(@"rankings_description"), @"find out who's the best right now"); @@ -39,6 +39,6 @@ namespace osu.Game.Localisation /// public static LocalisableString WikiDescription => new TranslatableString(getKey(@"wiki_description"), @"knowledge base"); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/UserInterfaceStrings.cs b/osu.Game/Localisation/UserInterfaceStrings.cs index 612668171c..dceedca05c 100644 --- a/osu.Game/Localisation/UserInterfaceStrings.cs +++ b/osu.Game/Localisation/UserInterfaceStrings.cs @@ -24,6 +24,11 @@ namespace osu.Game.Localisation /// public static LocalisableString MenuCursorSize => new TranslatableString(getKey(@"menu_cursor_size"), @"Menu cursor size"); + /// + /// "Menu tips" + /// + public static LocalisableString ShowMenuTips => new TranslatableString(getKey(@"show_menu_tips"), @"Menu tips"); + /// /// "Parallax" /// @@ -104,6 +109,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ModSelectHotkeyStyle => new TranslatableString(getKey(@"mod_select_hotkey_style"), @"Mod select hotkey style"); + /// + /// "Automatically focus search text box in mod select" + /// + public static LocalisableString ModSelectTextSearchStartsActive => new TranslatableString(getKey(@"mod_select_text_search_starts_active"), @"Automatically focus search text box in mod select"); + /// /// "no limit" /// @@ -149,6 +159,6 @@ namespace osu.Game.Localisation /// public static LocalisableString TrueRandom => new TranslatableString(getKey(@"true_random"), @"True Random"); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Models/RealmFile.cs b/osu.Game/Models/RealmFile.cs index 2faa3f0ca6..4d1642fb5f 100644 --- a/osu.Game/Models/RealmFile.cs +++ b/osu.Game/Models/RealmFile.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Game.IO; using Realms; @@ -11,5 +12,8 @@ namespace osu.Game.Models { [PrimaryKey] public string Hash { get; set; } = string.Empty; + + [Backlink(nameof(RealmNamedFileUsage.File))] + public IQueryable Usages { get; } = null!; } } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 21107d61fc..be5bdeca77 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -62,6 +62,9 @@ namespace osu.Game.Online.API private Bindable activity { get; } = new Bindable(); + private Bindable configStatus { get; } = new Bindable(); + private Bindable localUserStatus { get; } = new Bindable(); + protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); @@ -85,12 +88,20 @@ namespace osu.Game.Online.API authentication.TokenString = config.Get(OsuSetting.Token); authentication.Token.ValueChanged += onTokenChanged; + config.BindWith(OsuSetting.UserOnlineStatus, configStatus); + localUser.BindValueChanged(u => { u.OldValue?.Activity.UnbindFrom(activity); u.NewValue.Activity.BindTo(activity); + + if (u.OldValue != null) + localUserStatus.UnbindFrom(u.OldValue.Status); + localUserStatus.BindTo(u.NewValue.Status); }, true); + localUserStatus.BindValueChanged(val => configStatus.Value = val.NewValue); + var thread = new Thread(run) { Name = "APIAccess", @@ -200,6 +211,7 @@ namespace osu.Game.Online.API setLocalUser(new APIUser { Username = ProvidedUsername, + Status = { Value = configStatus.Value ?? UserStatus.Online } }); } @@ -246,8 +258,7 @@ namespace osu.Game.Online.API }; userReq.Success += user => { - // todo: save/pull from settings - user.Status.Value = UserStatus.Online; + user.Status.Value = configStatus.Value ?? UserStatus.Online; setLocalUser(user); diff --git a/osu.Game/Online/API/Requests/GetSystemTitleRequest.cs b/osu.Game/Online/API/Requests/GetSystemTitleRequest.cs new file mode 100644 index 0000000000..659e46bb11 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetSystemTitleRequest.cs @@ -0,0 +1,16 @@ +// 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.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetSystemTitleRequest : OsuJsonWebRequest + { + public GetSystemTitleRequest() + : base($@"https://assets.ppy.sh/lazer-status.json?{DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 1800}") + { + } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index 902b651be9..e5ecfe2c99 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -41,6 +41,10 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"difficulty_rating")] public double StarRating { get; set; } + public int EndTimeObjectCount => SliderCount + SpinnerCount; + + public int TotalObjectCount => CircleCount + SliderCount + SpinnerCount; + [JsonProperty(@"drain")] public float DrainRate { get; set; } @@ -108,7 +112,7 @@ namespace osu.Game.Online.API.Requests.Responses DrainRate = DrainRate, CircleSize = CircleSize, ApproachRate = ApproachRate, - OverallDifficulty = OverallDifficulty, + OverallDifficulty = OverallDifficulty }; IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; diff --git a/osu.Game/Online/API/Requests/Responses/APISystemTitle.cs b/osu.Game/Online/API/Requests/Responses/APISystemTitle.cs new file mode 100644 index 0000000000..bfa5c1043b --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APISystemTitle.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; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APISystemTitle : IEquatable + { + [JsonProperty(@"image")] + public string Image { get; set; } = string.Empty; + + [JsonProperty(@"url")] + public string Url { get; set; } = string.Empty; + + public bool Equals(APISystemTitle? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Image == other.Image && Url == other.Url; + } + + public override bool Equals(object? obj) => obj is APISystemTitle other && Equals(other); + + // ReSharper disable NonReadonlyMemberInGetHashCode + public override int GetHashCode() => HashCode.Combine(Image, Url); + } +} diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs index ac2d8152b1..732da3d5da 100644 --- a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -150,6 +150,12 @@ namespace osu.Game.Online.API.Requests.Responses #endregion + /// + /// Whether this represents a legacy (osu!stable) score. + /// + [JsonIgnore] + public bool IsLegacyScore => LegacyScoreId != null; + public override string ToString() => $"score_id: {ID} user_id: {UserID}"; /// @@ -191,6 +197,7 @@ namespace osu.Game.Online.API.Requests.Responses { OnlineID = OnlineID, LegacyOnlineID = (long?)LegacyScoreId ?? -1, + IsLegacyScore = IsLegacyScore, User = User ?? new APIUser { Id = UserID }, BeatmapInfo = new BeatmapInfo { OnlineID = BeatmapID }, Ruleset = new RulesetInfo { OnlineID = RulesetID }, diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index e95bc128c8..23989caae2 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -247,7 +247,7 @@ namespace osu.Game.Online.Chat string command = parameters[0]; string content = parameters.Length == 2 ? parameters[1] : string.Empty; - switch (command) + switch (command.ToLowerInvariant()) { case "np": AddInternal(new NowPlayingCommand(target)); diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 93aa0b95a7..67f2590ad8 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -152,6 +152,15 @@ namespace osu.Game.Online.Leaderboards /// public void RefetchScores() => Scheduler.AddOnce(refetchScores); + /// + /// Clear all scores from the display. + /// + public void ClearScores() + { + cancelPendingWork(); + SetScores(null); + } + /// /// Call when a retrieval or display failure happened to show a relevant message to the user. /// @@ -220,9 +229,7 @@ namespace osu.Game.Online.Leaderboards { Debug.Assert(ThreadSafety.IsUpdateThread); - cancelPendingWork(); - - SetScores(null); + ClearScores(); setState(LeaderboardState.Retrieving); currentFetchCancellationSource = new CancellationTokenSource(); diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index 27093d7961..c42c3378b7 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -23,7 +23,6 @@ namespace osu.Game.Online.Metadata public override IBindable IsWatchingUserPresence => isWatchingUserPresence; private readonly BindableBool isWatchingUserPresence = new BindableBool(); - // ReSharper disable once InconsistentlySynchronizedField public override IBindableDictionary UserStates => userStates; private readonly BindableDictionary userStates = new BindableDictionary(); @@ -97,8 +96,11 @@ namespace osu.Game.Online.Metadata { if (!connected.NewValue) { - isWatchingUserPresence.Value = false; - userStates.Clear(); + Schedule(() => + { + isWatchingUserPresence.Value = false; + userStates.Clear(); + }); return; } @@ -187,13 +189,13 @@ namespace osu.Game.Online.Metadata public override Task UserPresenceUpdated(int userId, UserPresence? presence) { - lock (userStates) + Schedule(() => { - if (presence != null) + if (presence?.Status != null) userStates[userId] = presence.Value; else userStates.Remove(userId); - } + }); return Task.CompletedTask; } @@ -205,7 +207,7 @@ namespace osu.Game.Online.Metadata Debug.Assert(connection != null); await connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence)).ConfigureAwait(false); - isWatchingUserPresence.Value = true; + Schedule(() => isWatchingUserPresence.Value = true); } public override async Task EndWatchingUserPresence() @@ -215,14 +217,14 @@ namespace osu.Game.Online.Metadata if (connector?.IsConnected.Value != true) throw new OperationCanceledException(); - // must happen synchronously before any remote calls to avoid misordering. - userStates.Clear(); + // must be scheduled before any remote calls to avoid mis-ordering. + Schedule(() => userStates.Clear()); Debug.Assert(connection != null); await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false); } finally { - isWatchingUserPresence.Value = false; + Schedule(() => isWatchingUserPresence.Value = false); } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 8d9e029c9d..0d6eb1ea44 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -994,8 +994,11 @@ namespace osu.Game Margin = new MarginPadding(5), }, topMostOverlayContent.Add); - if (!args?.Any(a => a == @"--no-version-overlay") ?? true) - loadComponentSingleFile(versionManager = new VersionManager { Depth = int.MinValue }, ScreenContainer.Add); + if (!IsDeployedBuild) + { + dependencies.Cache(versionManager = new VersionManager { Depth = int.MinValue }); + loadComponentSingleFile(versionManager, ScreenContainer.Add); + } loadComponentSingleFile(osuLogo, _ => { @@ -1190,7 +1193,7 @@ namespace osu.Game } else if (recentLogCount == short_term_display_limit) { - string logFile = $@"{entry.Target.Value.ToString().ToLowerInvariant()}.log"; + string logFile = Logger.GetLogger(entry.Target.Value).Filename; Schedule(() => Notifications.Post(new SimpleNotification { @@ -1198,7 +1201,7 @@ namespace osu.Game Text = NotificationsStrings.SubsequentMessagesLogged, Activated = () => { - Storage.GetStorageForDirectory(@"logs").PresentFileExternally(logFile); + Logger.Storage.PresentFileExternally(logFile); return true; } })); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 2d8024a45a..4e465f59df 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -200,6 +200,8 @@ namespace osu.Game private RulesetConfigCache rulesetConfigCache; + private SessionAverageHitErrorTracker hitErrorTracker; + protected SpectatorClient SpectatorClient { get; private set; } protected MultiplayerClient MultiplayerClient { get; private set; } @@ -349,6 +351,7 @@ namespace osu.Game dependencies.CacheAs(powerStatus); dependencies.Cache(SessionStatics = new SessionStatics()); + dependencies.Cache(hitErrorTracker = new SessionAverageHitErrorTracker()); dependencies.Cache(Colours = new OsuColour()); RegisterImportHandler(BeatmapManager); @@ -408,6 +411,7 @@ namespace osu.Game }); base.Content.Add(new TouchInputInterceptor()); + base.Content.Add(hitErrorTracker); KeyBindingStore = new RealmKeyBindingStore(realm, keyCombinationProvider); KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets); @@ -478,7 +482,7 @@ namespace osu.Game AddFont(Resources, @"Fonts/Venera/Venera-Bold"); AddFont(Resources, @"Fonts/Venera/Venera-Black"); - Fonts.AddStore(new HexaconsIcons.HexaconsStore(Textures)); + Fonts.AddStore(new OsuIcon.OsuIconStore(Textures)); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => @@ -527,14 +531,21 @@ namespace osu.Game { ManualResetEventSlim readyToRun = new ManualResetEventSlim(); + bool success = false; + Scheduler.Add(() => { - realmBlocker = realm.BlockAllOperations("migration"); + try + { + realmBlocker = realm.BlockAllOperations("migration"); + success = true; + } + catch { } readyToRun.Set(); }, false); - if (!readyToRun.Wait(30000)) + if (!readyToRun.Wait(30000) || !success) throw new TimeoutException("Attempting to block for migration took too long."); bool? cleanupSucceded = (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs index 27fab82bf3..075dfd02b0 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.BeatmapListing { Title = PageTitleStrings.MainBeatmapsetsControllerIndex; Description = NamedOverlayComponentStrings.BeatmapListingDescription; - Icon = HexaconsIcons.Beatmap; + Icon = OsuIcon.Beatmap; } } } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs index eced27f35e..1df246ae77 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs @@ -60,7 +60,7 @@ namespace osu.Game.Overlays.BeatmapSet public BeatmapHeaderTitle() { Title = PageTitleStrings.MainBeatmapsetsControllerShow; - Icon = HexaconsIcons.Beatmap; + Icon = OsuIcon.Beatmap; } } } diff --git a/osu.Game/Overlays/Changelog/ChangelogHeader.cs b/osu.Game/Overlays/Changelog/ChangelogHeader.cs index 61ea9dc4db..f738d70370 100644 --- a/osu.Game/Overlays/Changelog/ChangelogHeader.cs +++ b/osu.Game/Overlays/Changelog/ChangelogHeader.cs @@ -124,7 +124,7 @@ namespace osu.Game.Overlays.Changelog { Title = PageTitleStrings.MainChangelogControllerDefault; Description = NamedOverlayComponentStrings.ChangelogDescription; - Icon = HexaconsIcons.Devtools; + Icon = OsuIcon.ChangelogB; } } } diff --git a/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs b/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs index 4fc9fbb6d5..3ecdb09976 100644 --- a/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs +++ b/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs @@ -46,7 +46,7 @@ namespace osu.Game.Overlays.Chat { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Icon = HexaconsIcons.Messaging, + Icon = OsuIcon.Chat, Size = new Vector2(24), }, // Placeholder text diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 4aa4c59471..8f3b7031c2 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -31,7 +31,7 @@ namespace osu.Game.Overlays { public partial class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, IKeyBindingHandler { - public IconUsage Icon => HexaconsIcons.Messaging; + public IconUsage Icon => OsuIcon.Chat; public LocalisableString Title => ChatStrings.HeaderTitle; public LocalisableString Description => ChatStrings.HeaderDescription; diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs index fe3151398f..ee277ff538 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -130,9 +131,6 @@ namespace osu.Game.Overlays.Dashboard { int userId = kvp.Key; - if (userId == api.LocalUser.Value.Id) - continue; - users.GetUserAsync(userId).ContinueWith(task => { APIUser user = task.GetResultSafely(); @@ -218,6 +216,7 @@ namespace osu.Game.Overlays.Dashboard { panel.Anchor = Anchor.TopCentre; panel.Origin = Anchor.TopCentre; + panel.CanSpectate.Value = playingUsers.Contains(user.Id); }); public partial class OnlineUserPanel : CompositeDrawable, IFilterable diff --git a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs index 104f0943dc..8fd8f6b332 100644 --- a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs +++ b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.Dashboard { Title = PageTitleStrings.MainHomeControllerIndex; Description = NamedOverlayComponentStrings.DashboardDescription; - Icon = HexaconsIcons.Social; + Icon = OsuIcon.Global; } } } diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 36a9baac67..4ac37a63e2 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osuTK; @@ -25,14 +26,13 @@ namespace osu.Game.Overlays.Dialog public abstract partial class PopupDialog : VisibilityContainer { public const float ENTER_DURATION = 500; - public const float EXIT_DURATION = 200; + public const float EXIT_DURATION = 500; private readonly Vector2 ringSize = new Vector2(100f); private readonly Vector2 ringMinifiedSize = new Vector2(20f); - private readonly Vector2 buttonsEnterSpacing = new Vector2(0f, 50f); private readonly Box flashLayer; - private Sample flashSample = null!; + private Sample? flashSample; private readonly Container content; private readonly Container ring; @@ -108,13 +108,20 @@ namespace osu.Game.Overlays.Dialog protected PopupDialog() { - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; Children = new Drawable[] { content = new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Alpha = 0f, Children = new Drawable[] { @@ -122,11 +129,13 @@ namespace osu.Game.Overlays.Dialog { RelativeSizeAxes = Axes.Both, Masking = true, + CornerRadius = 20, + CornerExponent = 2.5f, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.5f), - Radius = 8, + Colour = Color4.Black.Opacity(0.2f), + Radius = 14, }, Children = new Drawable[] { @@ -142,23 +151,29 @@ namespace osu.Game.Overlays.Dialog ColourDark = Color4Extensions.FromHex(@"1e171e"), TriangleScale = 4, }, + flashLayer = new Box + { + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Colour = Color4Extensions.FromHex(@"221a21"), + }, }, }, new FillFlowContainer { - Anchor = Anchor.Centre, - Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, Spacing = new Vector2(0f, 10f), - Padding = new MarginPadding { Bottom = 10 }, + Padding = new MarginPadding { Vertical = 60 }, Children = new Drawable[] { new Container { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, + Padding = new MarginPadding { Bottom = 30 }, Size = ringSize, Children = new Drawable[] { @@ -181,6 +196,7 @@ namespace osu.Game.Overlays.Dialog Origin = Anchor.Centre, Anchor = Anchor.Centre, Icon = FontAwesome.Solid.TimesCircle, + Y = -2, Size = new Vector2(50), }, }, @@ -194,6 +210,7 @@ namespace osu.Game.Overlays.Dialog RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.TopCentre, + Padding = new MarginPadding { Horizontal = 5 }, }, body = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 18)) { @@ -202,25 +219,19 @@ namespace osu.Game.Overlays.Dialog TextAnchor = Anchor.TopCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(5), + Padding = new MarginPadding { Horizontal = 5 }, + }, + buttonsContainer = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Top = 30 }, }, }, }, - buttonsContainer = new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - }, - flashLayer = new Box - { - Alpha = 0, - RelativeSizeAxes = Axes.Both, - Blending = BlendingParameters.Additive, - Colour = Color4Extensions.FromHex(@"221a21"), - }, }, }, }; @@ -231,7 +242,7 @@ namespace osu.Game.Overlays.Dialog } [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load(AudioManager audio, OsuColour colours) { flashSample = audio.Samples.Get(@"UI/default-select-disabled"); } @@ -256,7 +267,7 @@ namespace osu.Game.Overlays.Dialog flashLayer.FadeInFromZero(80, Easing.OutQuint) .Then() .FadeOutFromOne(1500, Easing.OutQuint); - flashSample.Play(); + flashSample?.Play(); } protected override bool OnKeyDown(KeyDownEvent e) @@ -288,15 +299,15 @@ namespace osu.Game.Overlays.Dialog // Reset various animations but only if the dialog animation fully completed if (content.Alpha == 0) { - buttonsContainer.TransformSpacingTo(buttonsEnterSpacing); - buttonsContainer.MoveToY(buttonsEnterSpacing.Y); + content.ScaleTo(0.7f); ring.ResizeTo(ringMinifiedSize); } - content.FadeIn(ENTER_DURATION, Easing.OutQuint); - ring.ResizeTo(ringSize, ENTER_DURATION, Easing.OutQuint); - buttonsContainer.TransformSpacingTo(Vector2.Zero, ENTER_DURATION, Easing.OutQuint); - buttonsContainer.MoveToY(0, ENTER_DURATION, Easing.OutQuint); + content + .ScaleTo(1, 750, Easing.OutElasticHalf) + .FadeIn(ENTER_DURATION, Easing.OutQuint); + + ring.ResizeTo(ringSize, ENTER_DURATION * 1.5f, Easing.OutQuint); } protected override void PopOut() @@ -306,7 +317,9 @@ namespace osu.Game.Overlays.Dialog // This is presumed to always be a sane default "cancel" action. buttonsContainer.Last().TriggerClick(); - content.FadeOut(EXIT_DURATION, Easing.InSine); + content + .ScaleTo(0.7f, EXIT_DURATION, Easing.Out) + .FadeOut(EXIT_DURATION, Easing.OutQuint); } private void pressButtonAtIndex(int index) diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 005162bbcc..a85f1ecbcd 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -29,16 +29,18 @@ namespace osu.Game.Overlays public DialogOverlay() { - RelativeSizeAxes = Axes.Both; + AutoSizeAxes = Axes.Y; Child = dialogContainer = new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, }; - Width = 0.4f; - Anchor = Anchor.BottomCentre; - Origin = Anchor.BottomCentre; + Width = 500; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs index 23f3b3e1af..24ac5e72e8 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs @@ -244,6 +244,8 @@ namespace osu.Game.Overlays.FirstRunSetup [Resolved(canBeNull: true)] // Can't really be null but required to handle potential of disposal before DI completes. private OsuGameBase? game { get; set; } + private bool changingDirectory; + protected override void LoadComplete() { base.LoadComplete(); @@ -259,24 +261,37 @@ namespace osu.Game.Overlays.FirstRunSetup private void onDirectorySelected(ValueChangedEvent directory) { - if (directory.NewValue == null) - { - Current.Value = string.Empty; + if (changingDirectory) return; + + try + { + changingDirectory = true; + + if (directory.NewValue == null) + { + Current.Value = string.Empty; + return; + } + + // DirectorySelectors can trigger a noop value changed, but `DirectoryInfo` equality doesn't catch this. + if (directory.OldValue?.FullName == directory.NewValue.FullName) + return; + + if (legacyImportManager.IsUsableForStableImport(directory.NewValue, out var stableRoot)) + { + this.HidePopover(); + + string path = stableRoot.FullName; + + legacyImportManager.UpdateStorage(path); + Current.Value = path; + currentDirectory.Value = stableRoot; + } } - - // DirectorySelectors can trigger a noop value changed, but `DirectoryInfo` equality doesn't catch this. - if (directory.OldValue?.FullName == directory.NewValue.FullName) - return; - - if (directory.NewValue?.GetFiles(@"osu!.*.cfg").Any() ?? false) + finally { - this.HidePopover(); - - string path = directory.NewValue.FullName; - - legacyImportManager.UpdateStorage(path); - Current.Value = path; + changingDirectory = false; } } diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index 19af95459f..b0af2fa273 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -143,6 +143,8 @@ namespace osu.Game.Overlays.Login panel.Status.BindTo(api.LocalUser.Value.Status); panel.Activity.BindTo(api.LocalUser.Value.Activity); + panel.Status.BindValueChanged(_ => updateDropdownCurrent(), true); + dropdown.Current.BindValueChanged(action => { switch (action.NewValue) @@ -174,6 +176,24 @@ namespace osu.Game.Overlays.Login ScheduleAfterChildren(() => GetContainingInputManager()?.ChangeFocus(form)); }); + private void updateDropdownCurrent() + { + switch (panel.Status.Value) + { + case UserStatus.Online: + dropdown.Current.Value = UserAction.Online; + break; + + case UserStatus.DoNotDisturb: + dropdown.Current.Value = UserAction.DoNotDisturb; + break; + + case UserStatus.Offline: + dropdown.Current.Value = UserAction.AppearOffline; + break; + } + } + public override bool AcceptsFocus => true; protected override bool OnClick(ClickEvent e) => true; diff --git a/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs b/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs new file mode 100644 index 0000000000..957ee23e3b --- /dev/null +++ b/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs @@ -0,0 +1,142 @@ +// 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.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public partial class AdjustedAttributesTooltip : VisibilityContainer, ITooltip + { + private FillFlowContainer attributesFillFlow = null!; + + private Container content = null!; + + private Data? data; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + content = new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray3, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 10, Horizontal = 15 }, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "One or more values are being adjusted by mods that change speed.", + }, + attributesFillFlow = new FillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both + } + } + } + } + }, + }; + + updateDisplay(); + } + + private void updateDisplay() + { + attributesFillFlow.Clear(); + + if (data != null) + { + attemptAdd("CS", bd => bd.CircleSize); + attemptAdd("HP", bd => bd.DrainRate); + attemptAdd("OD", bd => bd.OverallDifficulty); + attemptAdd("AR", bd => bd.ApproachRate); + } + + if (attributesFillFlow.Any()) + content.Show(); + else + content.Hide(); + + void attemptAdd(string name, Func lookup) + { + double originalValue = lookup(data.OriginalDifficulty); + double adjustedValue = lookup(data.AdjustedDifficulty); + + if (!Precision.AlmostEquals(originalValue, adjustedValue)) + attributesFillFlow.Add(new AttributeDisplay(name, originalValue, adjustedValue)); + } + } + + public void SetContent(Data? data) + { + if (this.data == data) + return; + + this.data = data; + updateDisplay(); + } + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); + + public void Move(Vector2 pos) => Position = pos; + + public class Data + { + public BeatmapDifficulty OriginalDifficulty { get; } + public BeatmapDifficulty AdjustedDifficulty { get; } + + public Data(BeatmapDifficulty originalDifficulty, BeatmapDifficulty adjustedDifficulty) + { + OriginalDifficulty = originalDifficulty; + AdjustedDifficulty = adjustedDifficulty; + } + } + + private partial class AttributeDisplay : CompositeDrawable + { + public AttributeDisplay(string name, double original, double adjusted) + { + AutoSizeAxes = Axes.Both; + + InternalChild = new OsuSpriteText + { + Font = OsuFont.Default.With(weight: FontWeight.Bold), + Text = $"{name}: {original:0.0#} → {adjusted:0.0#}" + }; + } + } + } +} diff --git a/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs b/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs index 44c29e313b..94dd96ec1c 100644 --- a/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs +++ b/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs @@ -3,21 +3,23 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Input.Events; 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.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osuTK; -using System.Threading; -using osu.Framework.Input.Events; -using osu.Game.Configuration; namespace osu.Game.Overlays.Mods { @@ -25,7 +27,7 @@ namespace osu.Game.Overlays.Mods /// On the mod select overlay, this provides a local updating view of BPM, star rating and other /// difficulty attributes so the user can have a better insight into what mods are changing. /// - public partial class BeatmapAttributesDisplay : ModFooterInformationDisplay + public partial class BeatmapAttributesDisplay : ModFooterInformationDisplay, IHasCustomTooltip { private StarRatingDisplay starRatingDisplay = null!; private BPMDisplay bpmDisplay = null!; @@ -47,9 +49,18 @@ namespace osu.Game.Overlays.Mods [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + [Resolved] + private OsuGameBase game { get; set; } = null!; + + private IBindable gameRuleset = null!; + private CancellationTokenSource? cancellationSource; private IBindable starDifficulty = null!; + public ITooltip GetCustomTooltip() => new AdjustedAttributesTooltip(); + + public AdjustedAttributesTooltip.Data? TooltipContent { get; private set; } + private const float transition_duration = 250; [BackgroundDependencyLoader] @@ -80,8 +91,8 @@ namespace osu.Game.Overlays.Mods { circleSizeDisplay = new VerticalAttributeDisplay("CS") { Shear = new Vector2(-shear, 0), }, drainRateDisplay = new VerticalAttributeDisplay("HP") { Shear = new Vector2(-shear, 0), }, - approachRateDisplay = new VerticalAttributeDisplay("AR") { Shear = new Vector2(-shear, 0), }, overallDifficultyDisplay = new VerticalAttributeDisplay("OD") { Shear = new Vector2(-shear, 0), }, + approachRateDisplay = new VerticalAttributeDisplay("AR") { Shear = new Vector2(-shear, 0), }, }); } @@ -92,7 +103,6 @@ namespace osu.Game.Overlays.Mods mods.BindValueChanged(_ => { modSettingChangeTracker?.Dispose(); - modSettingChangeTracker = new ModSettingChangeTracker(mods.Value); modSettingChangeTracker.SettingChanged += _ => updateValues(); updateValues(); @@ -107,6 +117,11 @@ namespace osu.Game.Overlays.Mods updateCollapsedState(); }); + gameRuleset = game.Ruleset.GetBoundCopy(); + gameRuleset.BindValueChanged(_ => updateValues()); + + BeatmapInfo.BindValueChanged(_ => updateValues(), true); + updateCollapsedState(); } @@ -129,13 +144,8 @@ namespace osu.Game.Overlays.Mods private void startAnimating() { - Content.AutoSizeEasing = Easing.OutQuint; - Content.AutoSizeDuration = transition_duration; - } - - private void updateCollapsedState() - { - RightContent.FadeTo(Collapsed.Value && !IsHovered ? 0 : 1, transition_duration, Easing.OutQuint); + LeftContent.AutoSizeEasing = Content.AutoSizeEasing = Easing.OutQuint; + LeftContent.AutoSizeDuration = Content.AutoSizeDuration = transition_duration; } private void updateValues() => Scheduler.AddOnce(() => @@ -160,9 +170,18 @@ namespace osu.Game.Overlays.Mods bpmDisplay.Current.Value = BeatmapInfo.Value.BPM * rate; - BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(BeatmapInfo.Value.Difficulty); + BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(BeatmapInfo.Value.Difficulty); + foreach (var mod in mods.Value.OfType()) - mod.ApplyToDifficulty(adjustedDifficulty); + mod.ApplyToDifficulty(originalDifficulty); + + Ruleset ruleset = gameRuleset.Value.CreateInstance(); + BeatmapDifficulty adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); + + TooltipContent = new AdjustedAttributesTooltip.Data(originalDifficulty, adjustedDifficulty); + + approachRateDisplay.AdjustType.Value = VerticalAttributeDisplay.CalculateEffect(originalDifficulty.ApproachRate, adjustedDifficulty.ApproachRate); + overallDifficultyDisplay.AdjustType.Value = VerticalAttributeDisplay.CalculateEffect(originalDifficulty.OverallDifficulty, adjustedDifficulty.OverallDifficulty); circleSizeDisplay.Current.Value = adjustedDifficulty.CircleSize; drainRateDisplay.Current.Value = adjustedDifficulty.DrainRate; @@ -170,6 +189,11 @@ namespace osu.Game.Overlays.Mods overallDifficultyDisplay.Current.Value = adjustedDifficulty.OverallDifficulty; }); + private void updateCollapsedState() + { + RightContent.FadeTo(Collapsed.Value && !IsHovered ? 0 : 1, transition_duration, Easing.OutQuint); + } + private partial class BPMDisplay : RollingCounter { protected override double RollingDuration => 500; diff --git a/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs index 59a631a7b5..bf58efc339 100644 --- a/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs +++ b/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs @@ -20,7 +20,7 @@ namespace osu.Game.Overlays.Mods.Input { [Key.Q] = new[] { typeof(ModEasy) }, [Key.W] = new[] { typeof(ModNoFail) }, - [Key.E] = new[] { typeof(ModHalfTime) }, + [Key.E] = new[] { typeof(ModHalfTime), typeof(ModDaycore) }, [Key.A] = new[] { typeof(ModHardRock) }, [Key.S] = new[] { typeof(ModSuddenDeath), typeof(ModPerfect) }, [Key.D] = new[] { typeof(ModDoubleTime), typeof(ModNightcore) }, diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index f2b3264a84..baa7e594c1 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -115,6 +115,7 @@ namespace osu.Game.Overlays.Mods public IEnumerable AllAvailableMods => AvailableMods.Value.SelectMany(pair => pair.Value); private readonly BindableBool customisationVisible = new BindableBool(); + private Bindable textSearchStartsActive = null!; private ModSettingsArea modSettingsArea = null!; private ColumnScrollContainer columnScroll = null!; @@ -131,6 +132,8 @@ namespace osu.Game.Overlays.Mods protected ShearedToggleButton? CustomisationButton { get; private set; } protected SelectAllModsButton? SelectAllModsButton { get; set; } + private bool textBoxShouldFocus; + private Sample? columnAppearSample; private WorkingBeatmap? beatmap; @@ -154,7 +157,7 @@ namespace osu.Game.Overlays.Mods } [BackgroundDependencyLoader] - private void load(OsuGameBase game, OsuColour colours, AudioManager audio) + private void load(OsuGameBase game, OsuColour colours, AudioManager audio, OsuConfigManager configManager) { Header.Title = ModSelectOverlayStrings.ModSelectTitle; Header.Description = ModSelectOverlayStrings.ModSelectDescription; @@ -282,6 +285,8 @@ namespace osu.Game.Overlays.Mods } globalAvailableMods.BindTo(game.AvailableMods); + + textSearchStartsActive = configManager.GetBindable(OsuSetting.ModSelectTextSearchStartsActive); } public override void Hide() @@ -505,6 +510,11 @@ namespace osu.Game.Overlays.Mods modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic); TopLevelContent.MoveToY(-modAreaHeight, transition_duration, Easing.InOutCubic); + + if (customisationVisible.Value) + SearchTextBox.KillFocus(); + else + setTextBoxFocus(textBoxShouldFocus); } /// @@ -617,6 +627,8 @@ namespace osu.Game.Overlays.Mods nonFilteredColumnCount += 1; } + + setTextBoxFocus(textSearchStartsActive.Value); } protected override void PopOut() @@ -755,14 +767,20 @@ namespace osu.Game.Overlays.Mods return false; // TODO: should probably eventually support typical platform search shortcuts (`Ctrl-F`, `/`) - if (SearchTextBox.HasFocus) - SearchTextBox.KillFocus(); - else - SearchTextBox.TakeFocus(); - + setTextBoxFocus(!textBoxShouldFocus); return true; } + private void setTextBoxFocus(bool keepFocus) + { + textBoxShouldFocus = keepFocus; + + if (textBoxShouldFocus) + SearchTextBox.TakeFocus(); + else + SearchTextBox.KillFocus(); + } + #endregion #region Sample playback control diff --git a/osu.Game/Overlays/Mods/ModSettingsArea.cs b/osu.Game/Overlays/Mods/ModSettingsArea.cs index 6158c2c70f..54bfcc7199 100644 --- a/osu.Game/Overlays/Mods/ModSettingsArea.cs +++ b/osu.Game/Overlays/Mods/ModSettingsArea.cs @@ -32,6 +32,8 @@ namespace osu.Game.Overlays.Mods [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + public override bool AcceptsFocus => true; + public ModSettingsArea() { RelativeSizeAxes = Axes.X; diff --git a/osu.Game/Overlays/Mods/ScoreMultiplierDisplay.cs b/osu.Game/Overlays/Mods/ScoreMultiplierDisplay.cs index c758632392..b599b53082 100644 --- a/osu.Game/Overlays/Mods/ScoreMultiplierDisplay.cs +++ b/osu.Game/Overlays/Mods/ScoreMultiplierDisplay.cs @@ -4,7 +4,6 @@ using System; 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.Graphics.Shapes; @@ -15,6 +14,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; using osuTK; namespace osu.Game.Overlays.Mods @@ -147,7 +147,7 @@ namespace osu.Game.Overlays.Mods { protected override double RollingDuration => 500; - protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(@"0.00x"); + protected override LocalisableString FormatCount(double count) => ModUtils.FormatScoreMultiplier(count); protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText { diff --git a/osu.Game/Overlays/Mods/VerticalAttributeDisplay.cs b/osu.Game/Overlays/Mods/VerticalAttributeDisplay.cs index 60cc875dbb..33b7eaae1c 100644 --- a/osu.Game/Overlays/Mods/VerticalAttributeDisplay.cs +++ b/osu.Game/Overlays/Mods/VerticalAttributeDisplay.cs @@ -1,15 +1,20 @@ // 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.Bindables; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osuTK.Graphics; namespace osu.Game.Overlays.Mods { @@ -23,11 +28,45 @@ namespace osu.Game.Overlays.Mods private readonly BindableWithCurrent current = new BindableWithCurrent(); + public Bindable AdjustType = new Bindable(); + /// /// Text to display in the top area of the display. /// public LocalisableString Label { get; protected set; } + private readonly EffectCounter counter; + private readonly OsuSpriteText text; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private void updateTextColor() + { + Color4 newColor; + + switch (AdjustType.Value) + { + case ModEffect.NotChanged: + newColor = Color4.White; + break; + + case ModEffect.DifficultyReduction: + newColor = colours.ForModType(ModType.DifficultyReduction); + break; + + case ModEffect.DifficultyIncrease: + newColor = colours.ForModType(ModType.DifficultyIncrease); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(AdjustType.Value)); + } + + text.Colour = newColor; + counter.Colour = newColor; + } + public VerticalAttributeDisplay(LocalisableString label) { Label = label; @@ -37,15 +76,18 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.CentreLeft; Anchor = Anchor.CentreLeft; + AdjustType.BindValueChanged(_ => updateTextColor()); + InternalChild = new FillFlowContainer { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.Y, + Width = 50, Direction = FillDirection.Vertical, Children = new Drawable[] { - new OsuSpriteText + text = new OsuSpriteText { Origin = Anchor.Centre, Anchor = Anchor.Centre, @@ -53,7 +95,7 @@ namespace osu.Game.Overlays.Mods Margin = new MarginPadding { Horizontal = 15 }, // to reserve space for 0.XX value Font = OsuFont.Default.With(size: 20, weight: FontWeight.Bold) }, - new EffectCounter + counter = new EffectCounter { Origin = Anchor.Centre, Anchor = Anchor.Centre, @@ -63,11 +105,28 @@ namespace osu.Game.Overlays.Mods }; } + public static ModEffect CalculateEffect(double oldValue, double newValue) + { + if (Precision.AlmostEquals(newValue, oldValue, 0.01)) + return ModEffect.NotChanged; + if (newValue < oldValue) + return ModEffect.DifficultyReduction; + + return ModEffect.DifficultyIncrease; + } + + public enum ModEffect + { + NotChanged, + DifficultyReduction, + DifficultyIncrease, + } + private partial class EffectCounter : RollingCounter { protected override double RollingDuration => 500; - protected override LocalisableString FormatCount(double count) => count.ToLocalisableString("0.0"); + protected override LocalisableString FormatCount(double count) => count.ToLocalisableString("0.0#"); protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText { diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs index f237ed66f2..92d71a21ef 100644 --- a/osu.Game/Overlays/News/NewsHeader.cs +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -69,7 +69,7 @@ namespace osu.Game.Overlays.News { Title = PageTitleStrings.MainNewsControllerDefault; Description = NamedOverlayComponentStrings.NewsDescription; - Icon = HexaconsIcons.News; + Icon = OsuIcon.News; } } } diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index c3ddb228ea..18a487a312 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -29,7 +29,7 @@ namespace osu.Game.Overlays { public partial class NotificationOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, INotificationOverlay { - public IconUsage Icon => HexaconsIcons.Notification; + public IconUsage Icon => OsuIcon.Notification; public LocalisableString Title => NotificationsStrings.HeaderTitle; public LocalisableString Description => NotificationsStrings.HeaderDescription; @@ -227,7 +227,7 @@ namespace osu.Game.Overlays protected override void PopIn() { this.MoveToX(0, TRANSITION_LENGTH, Easing.OutQuint); - mainContent.FadeTo(1, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeTo(1, TRANSITION_LENGTH / 2, Easing.OutQuint); mainContent.FadeEdgeEffectTo(WaveContainer.SHADOW_OPACITY, WaveContainer.APPEAR_DURATION, Easing.Out); toastTray.FlushAllToasts(); @@ -240,7 +240,7 @@ namespace osu.Game.Overlays markAllRead(); this.MoveToX(WIDTH, TRANSITION_LENGTH, Easing.OutQuint); - mainContent.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeTo(0, TRANSITION_LENGTH / 2, Easing.OutQuint); mainContent.FadeEdgeEffectTo(0, WaveContainer.DISAPPEAR_DURATION, Easing.In); } diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 6ea032213e..2362cb11f6 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Notifications set { text = value; - Schedule(() => textDrawable.Text = text); + Scheduler.AddOnce(t => textDrawable.Text = t, text); } } diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 425ff0935d..7ec52364d4 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -29,7 +29,7 @@ namespace osu.Game.Overlays { public partial class NowPlayingOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent { - public IconUsage Icon => HexaconsIcons.Music; + public IconUsage Icon => OsuIcon.Music; public LocalisableString Title => NowPlayingStrings.HeaderTitle; public LocalisableString Description => NowPlayingStrings.HeaderDescription; diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index 08a816930e..85751e7457 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs @@ -145,7 +145,7 @@ namespace osu.Game.Overlays.Profile.Header bool anyInfoAdded = false; anyInfoAdded |= tryAddInfo(FontAwesome.Solid.MapMarker, user.Location); - anyInfoAdded |= tryAddInfo(OsuIcon.Heart, user.Interests); + anyInfoAdded |= tryAddInfo(FontAwesome.Solid.Heart, user.Interests); anyInfoAdded |= tryAddInfo(FontAwesome.Solid.Suitcase, user.Occupation); if (anyInfoAdded) diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index 36bd8a5af5..c9e5068b2a 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -232,6 +232,14 @@ namespace osu.Game.Overlays.Profile.Header bool expanded = coverToggle.CoverExpanded.Value; cover.ResizeHeightTo(expanded ? 250 : 0, transition_duration, Easing.OutQuint); + + // Without this a very tiny slither of the cover will be visible even with a size of zero. + // Integer masking woes, no doubt. + if (expanded) + cover.FadeIn(transition_duration, Easing.OutQuint); + else + cover.FadeOut(transition_duration, Easing.InQuint); + avatar.ResizeTo(new Vector2(expanded ? 120 : content_height), transition_duration, Easing.OutQuint); avatar.TransformTo(nameof(avatar.CornerRadius), expanded ? 40f : 20f, transition_duration, Easing.OutQuint); flow.TransformTo(nameof(flow.Spacing), new Vector2(expanded ? 20f : 10f), transition_duration, Easing.OutQuint); diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index 78343d08f1..42bec50022 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -87,7 +87,7 @@ namespace osu.Game.Overlays.Profile public ProfileHeaderTitle() { Title = PageTitleStrings.MainUsersControllerDefault; - Icon = HexaconsIcons.Profile; + Icon = OsuIcon.Player; } } } diff --git a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs index 63128fb73d..a23ec18afe 100644 --- a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs +++ b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Rankings { Title = PageTitleStrings.MainRankingControllerDefault; Description = NamedOverlayComponentStrings.RankingsDescription; - Icon = HexaconsIcons.Rankings; + Icon = OsuIcon.Ranking; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs new file mode 100644 index 0000000000..90f5a59215 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs @@ -0,0 +1,162 @@ +// 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.Specialized; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osuTK; + +namespace osu.Game.Overlays.Settings.Sections.Audio +{ + public partial class AudioOffsetAdjustControl : SettingsItem + { + public IBindable SuggestedOffset => ((AudioOffsetPreview)Control).SuggestedOffset; + + [BackgroundDependencyLoader] + private void load() + { + LabelText = AudioSettingsStrings.AudioOffset; + } + + protected override Drawable CreateControl() => new AudioOffsetPreview(); + + private partial class AudioOffsetPreview : CompositeDrawable, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(); + + private readonly IBindableList averageHitErrorHistory = new BindableList(); + + public readonly Bindable SuggestedOffset = new Bindable(); + + private Container notchContainer = null!; + private TextFlowContainer hintText = null!; + private RoundedButton applySuggestion = null!; + + [BackgroundDependencyLoader] + private void load(SessionAverageHitErrorTracker hitErrorTracker) + { + averageHitErrorHistory.BindTo(hitErrorTracker.AverageHitErrorHistory); + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TimeSlider + { + RelativeSizeAxes = Axes.X, + Current = { BindTarget = Current }, + KeyboardStep = 1, + }, + notchContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 10, + Padding = new MarginPadding { Horizontal = Nub.DEFAULT_EXPANDED_SIZE / 2 }, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + hintText = new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 16)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + applySuggestion = new RoundedButton + { + RelativeSizeAxes = Axes.X, + Text = "Apply suggested offset", + Action = () => + { + if (SuggestedOffset.Value.HasValue) + current.Value = SuggestedOffset.Value.Value; + hitErrorTracker.ClearHistory(); + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + averageHitErrorHistory.BindCollectionChanged(updateDisplay, true); + SuggestedOffset.BindValueChanged(_ => updateHintText(), true); + } + + private void updateDisplay(object? _, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (SessionAverageHitErrorTracker.DataPoint dataPoint in e.NewItems!) + { + notchContainer.ForEach(n => n.Alpha *= 0.95f); + notchContainer.Add(new Box + { + RelativeSizeAxes = Axes.Y, + Width = 2, + RelativePositionAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = getXPositionForOffset(dataPoint.SuggestedGlobalAudioOffset) + }); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (SessionAverageHitErrorTracker.DataPoint dataPoint in e.OldItems!) + { + var notch = notchContainer.FirstOrDefault(n => n.X == getXPositionForOffset(dataPoint.SuggestedGlobalAudioOffset)); + Debug.Assert(notch != null); + notchContainer.Remove(notch, true); + } + + break; + + case NotifyCollectionChangedAction.Reset: + notchContainer.Clear(); + break; + } + + SuggestedOffset.Value = averageHitErrorHistory.Any() ? averageHitErrorHistory.Average(dataPoint => dataPoint.SuggestedGlobalAudioOffset) : null; + } + + private float getXPositionForOffset(double offset) => (float)(Math.Clamp(offset, current.MinValue, current.MaxValue) / (2 * current.MaxValue)); + + private void updateHintText() + { + hintText.Text = SuggestedOffset.Value == null + ? @"Play a few beatmaps to receive a suggested offset!" + : $@"Based on the last {averageHitErrorHistory.Count} play(s), the suggested offset is {SuggestedOffset.Value:N0} ms."; + applySuggestion.Enabled.Value = SuggestedOffset.Value != null; + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index 6b5c769853..e05d20a5db 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -7,7 +7,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio @@ -16,23 +15,17 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { protected override LocalisableString Header => AudioSettingsStrings.OffsetHeader; - public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "universal", "uo", "timing", "delay", "latency" }); + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "universal", "uo", "timing", "delay", "latency", "wizard" }); [BackgroundDependencyLoader] private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsSlider + new AudioOffsetAdjustControl { - LabelText = AudioSettingsStrings.AudioOffset, Current = config.GetBindable(OsuSetting.AudioOffset), - KeyboardStep = 1f }, - new SettingsButton - { - Text = AudioSettingsStrings.OffsetWizard - } }; } } diff --git a/osu.Game/Overlays/Settings/Sections/AudioSection.cs b/osu.Game/Overlays/Settings/Sections/AudioSection.cs index fb3d486776..1ab0d6c886 100644 --- a/osu.Game/Overlays/Settings/Sections/AudioSection.cs +++ b/osu.Game/Overlays/Settings/Sections/AudioSection.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Localisation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Audio; @@ -17,7 +18,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.VolumeUp + Icon = OsuIcon.Audio }; public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "sound" }); diff --git a/osu.Game/Overlays/Settings/Sections/DebugSection.cs b/osu.Game/Overlays/Settings/Sections/DebugSection.cs index 33a6f4c673..b84c441057 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSection.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSection.cs @@ -5,6 +5,7 @@ using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.DebugSettings; @@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.Bug + Icon = OsuIcon.Debug }; public DebugSection() diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs index b60689b611..463b3d1d09 100644 --- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs +++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Gameplay; @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Regular.DotCircle + Icon = OsuIcon.GameplayC }; public GameplaySection() diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index cf7f63211e..2af6e36b7f 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -23,6 +23,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { LabelText = GeneralSettingsStrings.LanguageDropdown, Current = game.CurrentLanguage, + AlwaysShowSearchBar = true, }, new SettingsCheckbox { diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 2f68b3a82f..3ff5556f4d 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Configuration; @@ -16,23 +15,27 @@ using osu.Game.Localisation; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Settings.Sections.Maintenance; using osu.Game.Updater; +using SharpCompress.Archives.Zip; namespace osu.Game.Overlays.Settings.Sections.General { public partial class UpdateSettings : SettingsSubsection { - [Resolved(CanBeNull = true)] - private UpdateManager updateManager { get; set; } - protected override LocalisableString Header => GeneralSettingsStrings.UpdateHeader; - private SettingsButton checkForUpdatesButton; + private SettingsButton checkForUpdatesButton = null!; - [Resolved(CanBeNull = true)] - private INotificationOverlay notifications { get; set; } + [Resolved] + private UpdateManager? updateManager { get; set; } - [BackgroundDependencyLoader(true)] - private void load(Storage storage, OsuConfigManager config, OsuGame game) + [Resolved] + private INotificationOverlay? notifications { get; set; } + + [Resolved] + private Storage storage { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, OsuGame? game) { Add(new SettingsEnumDropdown { @@ -54,7 +57,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { notifications?.Post(new SimpleNotification { - Text = GeneralSettingsStrings.RunningLatestRelease(game.Version), + Text = GeneralSettingsStrings.RunningLatestRelease(game!.Version), Icon = FontAwesome.Solid.CheckCircle, }); } @@ -74,6 +77,13 @@ namespace osu.Game.Overlays.Settings.Sections.General Action = () => storage.PresentExternally(), }); + Add(new SettingsButton + { + Text = GeneralSettingsStrings.ExportLogs, + Keywords = new[] { @"bug", "report", "logs", "files" }, + Action = () => Task.Run(exportLogs), + }); + Add(new SettingsButton { Text = GeneralSettingsStrings.ChangeFolderLocation, @@ -81,5 +91,44 @@ namespace osu.Game.Overlays.Settings.Sections.General }); } } + + private void exportLogs() + { + ProgressNotification notification = new ProgressNotification + { + State = ProgressNotificationState.Active, + Text = "Exporting logs...", + }; + + notifications?.Post(notification); + + const string archive_filename = "exports/compressed-logs.zip"; + + try + { + var logStorage = Logger.Storage; + + using (var outStream = storage.CreateFileSafely(archive_filename)) + using (var zip = ZipArchive.Create()) + { + foreach (string? f in logStorage.GetFiles(string.Empty, "*.log")) zip.AddEntry(f, logStorage.GetStream(f), true); + + zip.SaveTo(outStream); + } + } + catch + { + notification.State = ProgressNotificationState.Cancelled; + + // cleanup if export is failed or canceled. + storage.Delete(archive_filename); + throw; + } + + notification.CompletionText = "Exported logs! Click to view."; + notification.CompletionClickAction = () => storage.PresentFileExternally(archive_filename); + + notification.State = ProgressNotificationState.Completed; + } } } diff --git a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs index 2b043d40bc..2aa1008b1d 100644 --- a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs @@ -23,7 +23,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.Cog + Icon = OsuIcon.Settings }; [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs index 98f6908512..e1fa1eef9c 100644 --- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Graphics; @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.Laptop + Icon = OsuIcon.Graphics }; public GraphicsSection() diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs index 53d0f50605..adf05a71b9 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs @@ -140,7 +140,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input /// A generated from the full input state. /// The key which triggered this update, and should be used as the binding. public void UpdateKeyCombination(KeyCombination fullState, InputKey triggerKey) => - UpdateKeyCombination(new KeyCombination(fullState.Keys.Where(KeyCombination.IsModifierKey).Append(triggerKey))); + // TODO: Distinct() can be removed after https://github.com/ppy/osu-framework/pull/6130 is merged. + UpdateKeyCombination(new KeyCombination(fullState.Keys.Where(KeyCombination.IsModifierKey).Append(triggerKey).Distinct().ToArray())); public void UpdateKeyCombination(KeyCombination newCombination) { diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index a8f19cc91d..0204aa5e64 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Handlers; using osu.Framework.Localisation; using osu.Framework.Platform; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Input; @@ -20,7 +21,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.Keyboard + Icon = OsuIcon.Input }; public InputSection(KeyBindingPanel keyConfig) diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs index 1b935b0cec..3f12b9c0df 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs @@ -1,11 +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; using System.IO; -using System.Linq; using System.Threading.Tasks; +using osu.Framework.Allocation; using osu.Framework.Localisation; using osu.Framework.Screens; +using osu.Game.Database; namespace osu.Game.Overlays.Settings.Sections.Maintenance { @@ -13,9 +15,12 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { private readonly TaskCompletionSource taskCompletionSource; + [Resolved] + private LegacyImportManager legacyImportManager { get; set; } = null!; + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.Disabled; - protected override bool IsValidDirectory(DirectoryInfo? info) => info?.GetFiles("osu!.*.cfg").Any() ?? false; + protected override bool IsValidDirectory(DirectoryInfo? info) => legacyImportManager.IsUsableForStableImport(info, out _); public override LocalisableString HeaderText => "Please select your osu!stable install location"; @@ -26,7 +31,10 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance protected override void OnSelection(DirectoryInfo directory) { - taskCompletionSource.TrySetResult(directory.FullName); + if (!legacyImportManager.IsUsableForStableImport(directory, out var stableRoot)) + throw new InvalidOperationException($@"{nameof(OnSelection)} was called on an invalid directory. This should never happen."); + + taskCompletionSource.TrySetResult(stableRoot.FullName); this.Exit(); } diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index bb0a952164..bd90e4c35d 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Maintenance; @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.Wrench + Icon = OsuIcon.Maintenance }; public MaintenanceSection() diff --git a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs index c8faa3b697..1484f2c756 100644 --- a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs +++ b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Online; @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.GlobeAsia + Icon = OsuIcon.Online }; public OnlineSection() diff --git a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs index aaad1ec4e2..626264151f 100644 --- a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs +++ b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Rulesets; @@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.Chess + Icon = OsuIcon.Rulesets }; [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index e997e70157..9b04f208a7 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Database; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Overlays.SkinEditor; @@ -31,7 +32,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.PaintBrush + Icon = OsuIcon.SkinB }; private static readonly Live random_skin_info = new SkinInfo @@ -57,9 +58,11 @@ namespace osu.Game.Overlays.Settings.Sections { skinDropdown = new SkinSettingsDropdown { + AlwaysShowSearchBar = true, + AllowNonContiguousMatching = true, LabelText = SkinSettingsStrings.CurrentSkin, Current = skins.CurrentSkinInfo, - Keywords = new[] { @"skins" } + Keywords = new[] { @"skins" }, }, new SettingsButton { diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 4577fadb01..5e42c3035c 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -29,6 +29,11 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface Children = new Drawable[] { + new SettingsCheckbox + { + LabelText = UserInterfaceStrings.ShowMenuTips, + Current = config.GetBindable(OsuSetting.MenuTips) + }, new SettingsCheckbox { LabelText = UserInterfaceStrings.InterfaceVoices, diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index addf5ce163..49bd17dfde 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -42,6 +42,12 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface ClassicDefault = ModSelectHotkeyStyle.Classic }, new SettingsCheckbox + { + LabelText = UserInterfaceStrings.ModSelectTextSearchStartsActive, + Current = config.GetBindable(OsuSetting.ModSelectTextSearchStartsActive), + ClassicDefault = false + }, + new SettingsCheckbox { LabelText = GameplaySettingsStrings.BackgroundBlur, Current = config.GetBindable(OsuSetting.SongSelectBackgroundBlur), diff --git a/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs index 2ec9e32ea9..953ede25e1 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.UserInterface; @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Solid.LayerGroup + Icon = OsuIcon.UserInterface }; public UserInterfaceSection() diff --git a/osu.Game/Overlays/Settings/SettingsDropdown.cs b/osu.Game/Overlays/Settings/SettingsDropdown.cs index 5798d02e03..ec69224bb6 100644 --- a/osu.Game/Overlays/Settings/SettingsDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsDropdown.cs @@ -16,6 +16,18 @@ namespace osu.Game.Overlays.Settings { protected new OsuDropdown Control => (OsuDropdown)base.Control; + public bool AlwaysShowSearchBar + { + get => Control.AlwaysShowSearchBar; + set => Control.AlwaysShowSearchBar = value; + } + + public bool AllowNonContiguousMatching + { + get => Control.AllowNonContiguousMatching; + set => Control.AllowNonContiguousMatching = value; + } + public IEnumerable Items { get => Control.Items; diff --git a/osu.Game/Overlays/Settings/SettingsSidebar.cs b/osu.Game/Overlays/Settings/SettingsSidebar.cs index 06bc2fd788..ddbcd60ef6 100644 --- a/osu.Game/Overlays/Settings/SettingsSidebar.cs +++ b/osu.Game/Overlays/Settings/SettingsSidebar.cs @@ -1,21 +1,36 @@ // 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.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; namespace osu.Game.Overlays.Settings { - public partial class SettingsSidebar : ExpandingButtonContainer + public partial class SettingsSidebar : ExpandingContainer { - public const float DEFAULT_WIDTH = 70; - public const int EXPANDED_WIDTH = 200; + public const float CONTRACTED_WIDTH = 70; + public const int EXPANDED_WIDTH = 170; - public SettingsSidebar() - : base(DEFAULT_WIDTH, EXPANDED_WIDTH) + public Action? BackButtonAction; + + protected override bool ExpandOnHover => false; + + private readonly bool showBackButton; + + public SettingsSidebar(bool showBackButton) + : base(CONTRACTED_WIDTH, EXPANDED_WIDTH) { + this.showBackButton = showBackButton; + Expanded.Value = true; } [BackgroundDependencyLoader] @@ -27,6 +42,71 @@ namespace osu.Game.Overlays.Settings RelativeSizeAxes = Axes.Both, Depth = float.MaxValue }); + + if (showBackButton) + { + AddInternal(new BackButton + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Action = () => BackButtonAction?.Invoke(), + }); + } + } + + public partial class BackButton : SidebarButton + { + private Drawable content = null!; + + public BackButton() + : base(HoverSampleSet.Default) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(SettingsSidebar.EXPANDED_WIDTH); + + Padding = new MarginPadding(40); + + AddRange(new[] + { + content = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(30), + Shadow = true, + Icon = FontAwesome.Solid.ChevronLeft + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), + Text = @"back", + }, + } + } + }); + } + + protected override void UpdateState() + { + base.UpdateState(); + + content.FadeColour(IsHovered ? ColourProvider.Light1 : ColourProvider.Light3, FADE_DURATION, Easing.OutQuint); + } } } } diff --git a/osu.Game/Overlays/Settings/SidebarButton.cs b/osu.Game/Overlays/Settings/SidebarButton.cs index a63688762d..f58c2f41ef 100644 --- a/osu.Game/Overlays/Settings/SidebarButton.cs +++ b/osu.Game/Overlays/Settings/SidebarButton.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; @@ -23,6 +24,7 @@ namespace osu.Game.Overlays.Settings private void load() { BackgroundColour = ColourProvider.Background5; + Hover.Colour = ColourProvider.Light4; } protected override void LoadComplete() @@ -40,6 +42,9 @@ namespace osu.Game.Overlays.Settings protected override void OnHoverLost(HoverLostEvent e) => UpdateState(); - protected abstract void UpdateState(); + protected virtual void UpdateState() + { + Hover.FadeTo(IsHovered ? 0.1f : 0, FADE_DURATION, Easing.OutQuint); + } } } diff --git a/osu.Game/Overlays/Settings/SidebarIconButton.cs b/osu.Game/Overlays/Settings/SidebarIconButton.cs index 4e5b361460..f4b71207e3 100644 --- a/osu.Game/Overlays/Settings/SidebarIconButton.cs +++ b/osu.Game/Overlays/Settings/SidebarIconButton.cs @@ -60,26 +60,28 @@ namespace osu.Game.Overlays.Settings RelativeSizeAxes = Axes.X; Height = 46; + Padding = new MarginPadding(5); + AddRange(new Drawable[] { textIconContent = new Container { - Width = SettingsSidebar.DEFAULT_WIDTH, - RelativeSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.6f), Children = new Drawable[] { - headerText = new OsuSpriteText - { - Position = new Vector2(SettingsSidebar.DEFAULT_WIDTH + 10, 0), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, iconContainer = new ConstrainedIconContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, Size = new Vector2(20), + Margin = new MarginPadding { Left = 25 } + }, + headerText = new OsuSpriteText + { + Position = new Vector2(60, 0), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, }, } }, @@ -113,6 +115,8 @@ namespace osu.Game.Overlays.Settings protected override void UpdateState() { + base.UpdateState(); + if (Selected) { textIconContent.FadeColour(ColourProvider.Content1, FADE_DURATION, Easing.OutQuint); diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 746d451343..9076dadf93 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -3,25 +3,27 @@ #nullable disable +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections; using osu.Game.Overlays.Settings.Sections.Input; using osuTK.Graphics; -using System.Collections.Generic; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Localisation; namespace osu.Game.Overlays { public partial class SettingsOverlay : SettingsPanel, INamedOverlayComponent { - public IconUsage Icon => HexaconsIcons.Settings; + public IconUsage Icon => OsuIcon.Settings; public LocalisableString Title => SettingsStrings.HeaderTitle; public LocalisableString Description => SettingsStrings.HeaderDescription; @@ -49,12 +51,27 @@ namespace osu.Game.Overlays protected override Drawable CreateFooter() => new SettingsFooter(); public SettingsOverlay() - : base(true) + : base(false) { } public override bool AcceptsFocus => lastOpenedSubPanel == null || lastOpenedSubPanel.State.Value == Visibility.Hidden; + public void ShowAtControl() + where T : Drawable + { + Show(); + + // wait for load of sections + if (!SectionsContainer.Any()) + { + Scheduler.Add(ShowAtControl); + return; + } + + SectionsContainer.ScrollTo(SectionsContainer.ChildrenOfType().Single()); + } + private T createSubPanel(T subPanel) where T : SettingsSubPanel { @@ -72,16 +89,19 @@ namespace osu.Game.Overlays switch (state.NewValue) { case Visibility.Visible: - Sidebar?.FadeColour(Color4.DarkGray, 300, Easing.OutQuint); + Sidebar.Expanded.Value = false; + Sidebar.FadeColour(Color4.DarkGray, 300, Easing.OutQuint); SectionsContainer.FadeOut(300, Easing.OutQuint); ContentContainer.MoveToX(-PANEL_WIDTH, 500, Easing.OutQuint); lastOpenedSubPanel = panel; + break; case Visibility.Hidden: - Sidebar?.FadeColour(Color4.White, 300, Easing.OutQuint); + Sidebar.Expanded.Value = true; + Sidebar.FadeColour(Color4.White, 300, Easing.OutQuint); SectionsContainer.FadeIn(500, Easing.OutQuint); ContentContainer.MoveToX(0, 500, Easing.OutQuint); diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index 3bac6c400f..748673035b 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -33,7 +33,7 @@ namespace osu.Game.Overlays public const float TRANSITION_LENGTH = 600; - private const float sidebar_width = SettingsSidebar.DEFAULT_WIDTH; + private const float sidebar_width = SettingsSidebar.EXPANDED_WIDTH; /// /// The width of the settings panel content, excluding the sidebar. @@ -59,7 +59,7 @@ namespace osu.Game.Overlays protected override string PopInSampleName => "UI/settings-pop-in"; protected override double PopInOutSampleBalance => -OsuGameBase.SFX_STEREO_STRENGTH; - private readonly bool showSidebar; + private readonly bool showBackButton; private LoadingLayer loading; @@ -72,9 +72,9 @@ namespace osu.Game.Overlays [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); - protected SettingsPanel(bool showSidebar) + protected SettingsPanel(bool showBackButton) { - this.showSidebar = showSidebar; + this.showBackButton = showBackButton; RelativeSizeAxes = Axes.Y; AutoSizeAxes = Axes.X; } @@ -146,10 +146,11 @@ namespace osu.Game.Overlays } }); - if (showSidebar) + AddInternal(Sidebar = new SettingsSidebar(showBackButton) { - AddInternal(Sidebar = new SettingsSidebar { Width = sidebar_width }); - } + BackButtonAction = Hide, + Width = sidebar_width + }); CreateSections()?.ForEach(AddSection); } @@ -180,7 +181,7 @@ namespace osu.Game.Overlays Scheduler.AddDelayed(loadSections, TRANSITION_LENGTH / 3); Sidebar?.MoveToX(0, TRANSITION_LENGTH, Easing.OutQuint); - this.FadeTo(1, TRANSITION_LENGTH, Easing.OutQuint); + this.FadeTo(1, TRANSITION_LENGTH / 2, Easing.OutQuint); searchTextBox.TakeFocus(); searchTextBox.HoldFocus = true; @@ -196,7 +197,7 @@ namespace osu.Game.Overlays ContentContainer.MoveToX(-WIDTH + ExpandedPosition, TRANSITION_LENGTH, Easing.OutQuint); Sidebar?.MoveToX(-sidebar_width, TRANSITION_LENGTH, Easing.OutQuint); - this.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); + this.FadeTo(0, TRANSITION_LENGTH / 2, Easing.OutQuint); searchTextBox.HoldFocus = false; if (searchTextBox.HasFocus) @@ -285,7 +286,6 @@ namespace osu.Game.Overlays return; SectionsContainer.ScrollTo(section); - Sidebar.Expanded.Value = false; }, }; } diff --git a/osu.Game/Overlays/SettingsSubPanel.cs b/osu.Game/Overlays/SettingsSubPanel.cs index 1651975a74..440639f06b 100644 --- a/osu.Game/Overlays/SettingsSubPanel.cs +++ b/osu.Game/Overlays/SettingsSubPanel.cs @@ -1,17 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays.Settings; -using osuTK; namespace osu.Game.Overlays { @@ -25,63 +15,8 @@ namespace osu.Game.Overlays [BackgroundDependencyLoader] private void load() { - AddInternal(new BackButton - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Action = Hide - }); } protected override bool DimMainContent => false; // dimming is handled by main overlay - - public partial class BackButton : SidebarButton - { - private Container content; - - public BackButton() - : base(HoverSampleSet.Default) - { - } - - [BackgroundDependencyLoader] - private void load() - { - Size = new Vector2(SettingsSidebar.DEFAULT_WIDTH); - - AddRange(new Drawable[] - { - content = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(15), - Shadow = true, - Icon = FontAwesome.Solid.ChevronLeft - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Y = 15, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = @"back", - }, - } - } - }); - } - - protected override void UpdateState() - { - content.FadeColour(IsHovered ? ColourProvider.Light1 : ColourProvider.Light3, FADE_DURATION, Easing.OutQuint); - } - } } } diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index c0948c1eab..de13bd96d4 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -151,9 +151,12 @@ namespace osu.Game.Overlays base.Update(); if (!headerTextVisibilityCache.IsValid) + { // These toolbox grouped may be contracted to only show icons. // For now, let's hide the header to avoid text truncation weirdness in such cases. headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint); + headerTextVisibilityCache.Validate(); + } } protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) diff --git a/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs b/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs index 01cd3d97e0..8f8d899fad 100644 --- a/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs +++ b/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs @@ -136,9 +136,10 @@ namespace osu.Game.Overlays.SkinEditor { base.Update(); + Vector2 scale = drawable.DrawInfo.MatrixInverse.ExtractScale().Xy; drawableQuad = drawable.ToScreenSpace( drawable.DrawRectangle - .Inflate(SkinSelectionHandler.INFLATE_SIZE)); + .Inflate(SkinSelectionHandler.INFLATE_SIZE * scale)); var localSpaceQuad = ToLocalSpace(drawableQuad); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 262ce263bd..bedaf12c9b 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -17,6 +17,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; +using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens; @@ -58,6 +59,9 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private Bindable> mods { get; set; } = null!; + [Resolved] + private Bindable ruleset { get; set; } = null!; + [Resolved] private IBindable beatmap { get; set; } = null!; @@ -153,7 +157,11 @@ namespace osu.Game.Overlays.SkinEditor if (screen is Player) return; - var replayGeneratingMod = beatmap.Value.BeatmapInfo.Ruleset.CreateInstance().GetAutoplayMod(); + // the validity of the current game-wide beatmap + ruleset combination is enforced by song select. + // if we're anywhere else, the state is unknown and may not make sense, so forcibly set something that does. + if (screen is not PlaySongSelect) + ruleset.Value = beatmap.Value.BeatmapInfo.Ruleset; + var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod(); IReadOnlyList usableMods = mods.Value; diff --git a/osu.Game/Overlays/Toolbar/Toolbar.cs b/osu.Game/Overlays/Toolbar/Toolbar.cs index 93294a9d30..52fad2ba3b 100644 --- a/osu.Game/Overlays/Toolbar/Toolbar.cs +++ b/osu.Game/Overlays/Toolbar/Toolbar.cs @@ -164,11 +164,11 @@ namespace osu.Game.Overlays.Toolbar { new ToolbarNewsButton(), new ToolbarChangelogButton(), + new ToolbarWikiButton(), new ToolbarRankingsButton(), new ToolbarBeatmapListingButton(), new ToolbarChatButton(), new ToolbarSocialButton(), - new ToolbarWikiButton(), new ToolbarMusicButton(), //new ToolbarButton //{ @@ -224,9 +224,9 @@ namespace osu.Game.Overlays.Toolbar RelativeSizeAxes = Axes.X, Anchor = Anchor.BottomLeft, Alpha = 0, - Height = 100, + Height = 80, Colour = ColourInfo.GradientVertical( - OsuColour.Gray(0).Opacity(0.9f), OsuColour.Gray(0).Opacity(0)), + OsuColour.Gray(0f).Opacity(0.7f), OsuColour.Gray(0).Opacity(0)), }, }; } @@ -241,9 +241,9 @@ namespace osu.Game.Overlays.Toolbar private void updateState() { if (ShowGradient.Value) - gradientBackground.FadeIn(transition_time, Easing.OutQuint); + gradientBackground.FadeIn(2500, Easing.OutQuint); else - gradientBackground.FadeOut(transition_time, Easing.OutQuint); + gradientBackground.FadeOut(200, Easing.OutQuint); } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 08bcb6bd8a..03a1cfc005 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -7,7 +7,6 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; @@ -74,6 +73,8 @@ namespace osu.Game.Overlays.Toolbar private readonly SpriteText keyBindingTooltip; protected FillFlowContainer Flow; + protected readonly Container BackgroundContent; + [Resolved] private RealmAccess realm { get; set; } = null!; @@ -82,21 +83,33 @@ namespace osu.Game.Overlays.Toolbar Width = Toolbar.HEIGHT; RelativeSizeAxes = Axes.Y; + Padding = new MarginPadding(3); + Children = new Drawable[] { - HoverBackground = new Box + BackgroundContent = new Container { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(80).Opacity(180), - Blending = BlendingParameters.Additive, - Alpha = 0, - }, - flashBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Colour = Color4.White.Opacity(100), - Blending = BlendingParameters.Additive, + Masking = true, + CornerRadius = 6, + CornerExponent = 3f, + Children = new Drawable[] + { + HoverBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(80).Opacity(180), + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + flashBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Color4.White.Opacity(100), + Blending = BlendingParameters.Additive, + }, + } }, Flow = new FillFlowContainer { @@ -113,7 +126,7 @@ namespace osu.Game.Overlays.Toolbar { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(26), + Size = new Vector2(20), Alpha = 0, }, DrawableText = new OsuSpriteText @@ -157,19 +170,26 @@ namespace osu.Game.Overlays.Toolbar }; } + [BackgroundDependencyLoader] + private void load() + { + if (Hotkey != null) + { + realm.SubscribeToPropertyChanged(r => r.All().FirstOrDefault(rkb => rkb.RulesetName == null && rkb.ActionInt == (int)Hotkey.Value), kb => kb.KeyCombinationString, updateKeyBindingTooltip); + } + } + protected override bool OnMouseDown(MouseDownEvent e) => false; protected override bool OnClick(ClickEvent e) { - flashBackground.FadeOutFromOne(800, Easing.OutQuint); + flashBackground.FadeIn(50).Then().FadeOutFromOne(800, Easing.OutQuint); tooltipContainer.FadeOut(100); return base.OnClick(e); } protected override bool OnHover(HoverEvent e) { - updateKeyBindingTooltip(); - HoverBackground.FadeIn(200); tooltipContainer.FadeIn(100); @@ -197,19 +217,13 @@ namespace osu.Game.Overlays.Toolbar { } - private void updateKeyBindingTooltip() + private void updateKeyBindingTooltip(string keyCombination) { - if (Hotkey == null) return; + string keyBindingString = keyCombinationProvider.GetReadableString(keyCombination); - var realmKeyBinding = realm.Realm.All().FirstOrDefault(rkb => rkb.RulesetName == null && rkb.ActionInt == (int)Hotkey.Value); - - if (realmKeyBinding != null) - { - string keyBindingString = keyCombinationProvider.GetReadableString(realmKeyBinding.KeyCombination); - - if (!string.IsNullOrEmpty(keyBindingString)) - keyBindingTooltip.Text = $" ({keyBindingString})"; - } + keyBindingTooltip.Text = !string.IsNullOrEmpty(keyBindingString) + ? $" ({keyBindingString})" + : string.Empty; } } @@ -218,14 +232,6 @@ namespace osu.Game.Overlays.Toolbar public OpaqueBackground() { RelativeSizeAxes = Axes.Both; - Masking = true; - MaskingSmoothness = 0; - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(40), - Radius = 5, - }; Children = new Drawable[] { diff --git a/osu.Game/Overlays/Toolbar/ToolbarClock.cs b/osu.Game/Overlays/Toolbar/ToolbarClock.cs index f1310d8535..67688155ae 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarClock.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarClock.cs @@ -42,21 +42,33 @@ namespace osu.Game.Overlays.Toolbar clockDisplayMode = config.GetBindable(OsuSetting.ToolbarClockDisplayMode); prefer24HourTime = config.GetBindable(OsuSetting.Prefer24HourTime); + Padding = new MarginPadding(3); + Children = new Drawable[] { - hoverBackground = new Box + new Container { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(80).Opacity(180), - Blending = BlendingParameters.Additive, - Alpha = 0, - }, - flashBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Colour = Color4.White.Opacity(100), - Blending = BlendingParameters.Additive, + Masking = true, + CornerRadius = 6, + CornerExponent = 3f, + Children = new Drawable[] + { + hoverBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(80).Opacity(180), + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + flashBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Color4.White.Opacity(100), + Blending = BlendingParameters.Additive, + }, + } }, new FillFlowContainer { diff --git a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs index ded0229d67..70675c1b92 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs @@ -21,7 +21,7 @@ namespace osu.Game.Overlays.Toolbar { TooltipMain = ToolbarStrings.HomeHeaderTitle; TooltipSub = ToolbarStrings.HomeHeaderDescription; - SetIcon(HexaconsIcons.Home); + SetIcon(OsuIcon.Home); } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs b/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs index 78c976111b..06755a9da9 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs @@ -3,6 +3,7 @@ #nullable disable +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -14,7 +15,7 @@ namespace osu.Game.Overlays.Toolbar { public partial class ToolbarOverlayToggleButton : ToolbarButton { - private readonly Box stateBackground; + private Box stateBackground; private OverlayContainer stateContainer; @@ -44,14 +45,15 @@ namespace osu.Game.Overlays.Toolbar } } - public ToolbarOverlayToggleButton() + [BackgroundDependencyLoader] + private void load(OsuColour colours) { - Add(stateBackground = new Box + BackgroundContent.Add(stateBackground = new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(150).Opacity(180), + Colour = colours.Carmine.Opacity(180), Blending = BlendingParameters.Additive, - Depth = 2, + Depth = float.MaxValue, Alpha = 0, }); @@ -63,11 +65,11 @@ namespace osu.Game.Overlays.Toolbar switch (state.NewValue) { case Visibility.Hidden: - stateBackground.FadeOut(200); + stateBackground.FadeOut(200, Easing.OutQuint); break; case Visibility.Visible: - stateBackground.FadeIn(200); + stateBackground.FadeIn(200, Easing.OutQuint); break; } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs index 715076b368..723c24597a 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs @@ -4,20 +4,19 @@ #nullable disable using System.Collections.Generic; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Game.Rulesets; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osuTK.Input; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Rulesets; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Overlays.Toolbar { @@ -41,25 +40,24 @@ namespace osu.Game.Overlays.Toolbar new OpaqueBackground { Depth = 1, + Masking = true, }, ModeButtonLine = new Container { Size = new Vector2(Toolbar.HEIGHT, 3), Anchor = Anchor.BottomLeft, - Origin = Anchor.TopLeft, - Masking = true, - EdgeEffect = new EdgeEffectParameters + Origin = Anchor.BottomLeft, + Y = -1, + Children = new Drawable[] { - Type = EdgeEffectType.Glow, - Colour = new Color4(255, 194, 224, 100), - Radius = 15, - Roundness = 15, - }, - Child = new Box - { - RelativeSizeAxes = Axes.Both, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(18, 3), + } } - } + }, }); foreach (var ruleset in Rulesets.AvailableRulesets) @@ -89,7 +87,7 @@ namespace osu.Game.Overlays.Toolbar { if (SelectedTab != null) { - ModeButtonLine.MoveToX(SelectedTab.DrawPosition.X, !hasInitialPosition ? 0 : 200, Easing.OutQuint); + ModeButtonLine.MoveToX(SelectedTab.DrawPosition.X, !hasInitialPosition ? 0 : 500, Easing.OutElasticQuarter); if (hasInitialPosition) selectionSamples[SelectedTab.Value.ShortName]?.Play(); diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs index 74f76c7c89..5500f1c879 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs @@ -1,14 +1,16 @@ // 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.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Rulesets; -using osuTK.Graphics; namespace osu.Game.Overlays.Toolbar { @@ -41,27 +43,31 @@ namespace osu.Game.Overlays.Toolbar { protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); + [Resolved] + private OsuColour colours { get; set; } = null!; + + public RulesetButton() + { + Padding = new MarginPadding(3) + { + Bottom = 5 + }; + } + public bool Active { - set + set => Scheduler.AddOnce(() => { if (value) { - IconContainer.Colour = Color4.White; - IconContainer.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = new Color4(255, 194, 224, 100), - Radius = 15, - Roundness = 15, - }; + IconContainer.Colour = Color4Extensions.FromHex("#00FFAA"); } else { - IconContainer.Colour = new Color4(255, 194, 224, 255); + IconContainer.Colour = colours.GrayF; IconContainer.EdgeEffect = new EdgeEffectParameters(); } - } + }); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index 028decea1e..19b98628e6 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -40,8 +40,6 @@ namespace osu.Game.Overlays.Toolbar [BackgroundDependencyLoader] private void load(OsuColour colours, IAPIProvider api, LoginOverlay? login) { - Add(new OpaqueBackground { Depth = 1 }); - Flow.Add(new Container { Masking = true, diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs index 9e9e565684..d64d6b934a 100644 --- a/osu.Game/Overlays/Wiki/WikiHeader.cs +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -17,8 +17,6 @@ namespace osu.Game.Overlays.Wiki { public partial class WikiHeader : BreadcrumbControlOverlayHeader { - private const string index_path = "Main_Page"; - public static LocalisableString IndexPageString => LayoutStrings.HeaderHelpIndex; public readonly Bindable WikiPageData = new Bindable(); @@ -45,7 +43,7 @@ namespace osu.Game.Overlays.Wiki TabControl.AddItem(IndexPageString); - if (e.NewValue.Path == index_path) + if (e.NewValue.Path == WikiOverlay.INDEX_PATH) { Current.Value = IndexPageString; return; @@ -82,7 +80,7 @@ namespace osu.Game.Overlays.Wiki { Title = PageTitleStrings.MainWikiControllerDefault; Description = NamedOverlayComponentStrings.WikiDescription; - Icon = HexaconsIcons.Wiki; + Icon = OsuIcon.Wiki; } } } diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index c816eca776..ffbc168fb7 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -19,11 +19,11 @@ namespace osu.Game.Overlays { public partial class WikiOverlay : OnlineOverlay { - private const string index_path = @"main_page"; + public const string INDEX_PATH = @"Main_page"; public string CurrentPath => path.Value; - private readonly Bindable path = new Bindable(index_path); + private readonly Bindable path = new Bindable(INDEX_PATH); private readonly Bindable wikiData = new Bindable(); @@ -43,7 +43,7 @@ namespace osu.Game.Overlays { } - public void ShowPage(string pagePath = index_path) + public void ShowPage(string pagePath = INDEX_PATH) { path.Value = pagePath.Trim('/'); Show(); @@ -137,7 +137,7 @@ namespace osu.Game.Overlays wikiData.Value = response; path.Value = response.Path; - if (response.Layout == index_path) + if (response.Layout.Equals(INDEX_PATH, StringComparison.OrdinalIgnoreCase)) { LoadDisplay(new WikiMainPage { @@ -161,7 +161,7 @@ namespace osu.Game.Overlays path.Value = "error"; LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/", - $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page](Main_Page).")); + $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page]({INDEX_PATH}).")); } private void showParentPage() diff --git a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs index 0eea1ff215..418dc3576f 100644 --- a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs +++ b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs @@ -84,7 +84,7 @@ namespace osu.Game.Rulesets.Configuration if (setting != null) { - bindable.Parse(setting.Value); + bindable.Parse(setting.Value, CultureInfo.InvariantCulture); } else { diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index 6782c4324a..dcf5eb4da9 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -26,6 +26,7 @@ namespace osu.Game.Rulesets.Edit new CheckFewHitsounds(), new CheckTooShortAudioFiles(), new CheckAudioInVideo(), + new CheckDelayedHitsounds(), // Files new CheckZeroByteFiles(), diff --git a/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs b/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs new file mode 100644 index 0000000000..d6cd4f4caa --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.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.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Audio.Track; +using osu.Game.Audio; +using osu.Game.Extensions; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckDelayedHitsounds : ICheck + { + /// + /// Threshold at which point the sample is considered silent. + /// + private const float silence_threshold = 0.001f; + + private const float falloff_factor = 0.95f; + private const int delay_threshold = 5; + private const int delay_threshold_negligible = 1; + + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Delayed hit sounds."); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateConsequentDelay(this), + new IssueTemplateDelay(this), + new IssueTemplateDelayNoSilence(this), + new IssueTemplateMinorDelay(this), + new IssueTemplateMinorDelayNoSilence(this), + }; + + private float getAverageAmplitude(Waveform.Point point) => (point.AmplitudeLeft + point.AmplitudeRight) / 2; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; + + if (beatmapSet == null) + yield break; + + foreach (var file in beatmapSet.Files) + { + using (Stream? stream = context.WorkingBeatmap.GetStream(file.File.GetStoragePath())) + { + if (stream == null) + continue; + + if (!isHitSound(file.Filename)) + continue; + + using Waveform waveform = new Waveform(stream); + + var points = waveform.GetPoints(); + + // Skip muted samples + if (points.Length == 0 || points.Sum(getAverageAmplitude) <= silence_threshold) + continue; + + float maxAmplitude = points.Select(getAverageAmplitude).Max(); + + int consequentDelay = 0; + int delay = 0; + float amplitude = 0; + + while (delay + consequentDelay < points.Length) + { + amplitude += getAverageAmplitude(points[delay]); + + // Reached peak amplitude/transient + if (amplitude >= maxAmplitude) + break; + + amplitude *= falloff_factor; + + if (amplitude < silence_threshold) + { + amplitude = 0; + consequentDelay++; + } + + delay++; + } + + if (consequentDelay >= delay_threshold) + yield return new IssueTemplateConsequentDelay(this).Create(file.Filename, consequentDelay); + else if (consequentDelay + delay >= delay_threshold) + { + if (consequentDelay > 0) + yield return new IssueTemplateDelay(this).Create(file.Filename, consequentDelay, delay); + else + yield return new IssueTemplateDelayNoSilence(this).Create(file.Filename, delay); + } + else if (consequentDelay + delay >= delay_threshold_negligible) + { + if (consequentDelay > 0) + yield return new IssueTemplateMinorDelay(this).Create(file.Filename, consequentDelay, delay); + else + yield return new IssueTemplateMinorDelayNoSilence(this).Create(file.Filename, delay); + } + } + } + } + + private bool isHitSound(string filename) + { + if (!AudioCheckUtils.HasAudioExtension(filename)) + return false; + + // - + string[] parts = filename.ToLowerInvariant().Split('-'); + + if (parts.Length != 2) + return false; + + string bank = parts[0]; + string sampleSet = parts[1]; + + return HitSampleInfo.AllBanks.Contains(bank) + && HitSampleInfo.AllAdditions.Append(HitSampleInfo.HIT_NORMAL).Any(sampleSet.StartsWith); + } + + public class IssueTemplateConsequentDelay : IssueTemplate + { + public IssueTemplateConsequentDelay(ICheck check) + : base(check, IssueType.Problem, + "\"{0}\" has a {1:0.##} ms period of complete silence at the start.") + { + } + + public Issue Create(string filename, int pureDelay) => new Issue(this, filename, pureDelay); + } + + public class IssueTemplateDelay : IssueTemplate + { + public IssueTemplateDelay(ICheck check) + : base(check, IssueType.Warning, + "\"{0}\" has a transient delay of ~{1:0.##} ms, of which {2:0.##} ms is complete silence.") + { + } + + public Issue Create(string filename, int consequentDelay, int delay) => new Issue(this, filename, delay, consequentDelay); + } + + public class IssueTemplateDelayNoSilence : IssueTemplate + { + public IssueTemplateDelayNoSilence(ICheck check) + : base(check, IssueType.Warning, + "\"{0}\" has a transient delay of ~{1:0.##} ms.") + { + } + + public Issue Create(string filename, int delay) => new Issue(this, filename, delay); + } + + public class IssueTemplateMinorDelay : IssueTemplate + { + public IssueTemplateMinorDelay(ICheck check) + : base(check, IssueType.Negligible, + "\"{0}\" has a transient delay of ~{1:0.##} ms, of which {2:0.##} ms is complete silence.") + { + } + + public Issue Create(string filename, int consequentDelay, int delay) => new Issue(this, filename, delay, consequentDelay); + } + + public class IssueTemplateMinorDelayNoSilence : IssueTemplate + { + public IssueTemplateMinorDelayNoSilence(ICheck check) + : base(check, IssueType.Negligible, + "\"{0}\" has a transient delay of ~{1:0.##} ms.") + { + } + + public Issue Create(string filename, int delay) => new Issue(this, filename, delay); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs index 1c2ea36948..32a3aa5ad9 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; -using System.Linq; using ManagedBass; using osu.Framework.Audio.Callbacks; using osu.Game.Extensions; @@ -16,8 +15,6 @@ namespace osu.Game.Rulesets.Edit.Checks private const int ms_threshold = 25; private const int min_bytes_threshold = 100; - private readonly string[] audioExtensions = { "mp3", "ogg", "wav" }; - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Too short audio files"); public IEnumerable PossibleTemplates => new IssueTemplate[] @@ -46,7 +43,7 @@ namespace osu.Game.Rulesets.Edit.Checks { // If the file is not likely to be properly parsed by Bass, we don't produce Error issues about it. // Image files and audio files devoid of audio data both fail, for example, but neither would be issues in this check. - if (hasAudioExtension(file.Filename) && probablyHasAudioData(data)) + if (AudioCheckUtils.HasAudioExtension(file.Filename) && probablyHasAudioData(data)) yield return new IssueTemplateBadFormat(this).Create(file.Filename); continue; @@ -63,7 +60,6 @@ namespace osu.Game.Rulesets.Edit.Checks } } - private bool hasAudioExtension(string filename) => audioExtensions.Any(filename.ToLowerInvariant().EndsWith); private bool probablyHasAudioData(Stream data) => data.Length > min_bytes_threshold; public class IssueTemplateTooShort : IssueTemplate diff --git a/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs b/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs new file mode 100644 index 0000000000..b8cbe63c1e --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Linq; + +namespace osu.Game.Rulesets.Edit.Checks.Components +{ + public static class AudioCheckUtils + { + public static readonly string[] AUDIO_EXTENSIONS = { "mp3", "ogg", "wav" }; + + public static bool HasAudioExtension(string filename) => AUDIO_EXTENSIONS.Any(Path.GetExtension(filename).ToLowerInvariant().EndsWith); + } +} diff --git a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs index ddf539771d..b3ca59a5b0 100644 --- a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs @@ -16,6 +16,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Overlays; @@ -169,7 +170,7 @@ namespace osu.Game.Rulesets.Edit public IEnumerable CreateTernaryButtons() => new[] { - new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) + new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = OsuIcon.EditorDistanceSnap }) }; protected override bool OnKeyDown(KeyDownEvent e) diff --git a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs index 174b278d89..ebf06bcc4e 100644 --- a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs +++ b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; @@ -26,6 +27,9 @@ namespace osu.Game.Rulesets.Edit [Resolved] private EditorBeatmap beatmap { get; set; } = null!; + [Resolved] + private EditorClock editorClock { get; set; } = null!; + public DrawableEditorRulesetWrapper(DrawableRuleset drawableRuleset) { this.drawableRuleset = drawableRuleset; @@ -38,7 +42,6 @@ namespace osu.Game.Rulesets.Edit [BackgroundDependencyLoader] private void load() { - drawableRuleset.FrameStablePlayback = false; Playfield.DisplayJudgements.Value = false; } @@ -65,6 +68,22 @@ namespace osu.Game.Rulesets.Edit Scheduler.AddOnce(regenerateAutoplay); } + protected override void Update() + { + base.Update(); + + // Whenever possible, we want to stay in frame stability playback. + // Without doing so, we run into bugs with some gameplay elements not behaving as expected. + // + // Note that this is not using EditorClock.IsSeeking as that would exit frame stability + // on all seeks. The intention here is to retain frame stability for small seeks. + // + // I still think no gameplay elements should require frame stability in the first place, but maybe that ship has sailed already.. + bool shouldBypassFrameStability = Math.Abs(drawableRuleset.FrameStableClock.CurrentTime - editorClock.CurrentTime) > 1000; + + drawableRuleset.FrameStablePlayback = !shouldBypassFrameStability; + } + private void regenerateAutoplay() { var autoplayMod = drawableRuleset.Mods.OfType().Single(); diff --git a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs index 9640830a09..a272e9f480 100644 --- a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs +++ b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Edit.Tools { @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Edit.Tools { } - public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.MousePointer }; + public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorSelect }; public override PlacementBlueprint CreatePlacementBlueprint() => null; } diff --git a/osu.Game/Rulesets/ILegacyRuleset.cs b/osu.Game/Rulesets/ILegacyRuleset.cs index 6900afa243..18d86f477a 100644 --- a/osu.Game/Rulesets/ILegacyRuleset.cs +++ b/osu.Game/Rulesets/ILegacyRuleset.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Beatmaps; using osu.Game.Rulesets.Scoring.Legacy; namespace osu.Game.Rulesets @@ -14,6 +15,12 @@ namespace osu.Game.Rulesets /// int LegacyID { get; } + /// + /// Retrieves the number of mania keys required to play the beatmap. + /// + /// + int GetKeyCount(IBeatmapInfo beatmapInfo) => 0; + ILegacyScoreSimulator CreateLegacyScoreSimulator(); } } diff --git a/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs index d5f586dc35..ada651b60e 100644 --- a/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs +++ b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs @@ -38,18 +38,29 @@ namespace osu.Game.Rulesets.Judgements /// public virtual void PlayAnimation() { - switch (Result) + // TODO: make these better. currently they are using a text `-` and it's not centered properly. + // Should be an explicit drawable. + // + // When this is done, remove the [Description] attributes from HitResults which were added for this purpose. + if (Result == HitResult.IgnoreMiss || Result == HitResult.LargeTickMiss) { - case HitResult.Miss: - this.ScaleTo(1.6f); - this.ScaleTo(1, 100, Easing.In); + this.RotateTo(-45); + this.ScaleTo(1.8f); + this.ScaleTo(1.2f, 100, Easing.In); - this.MoveTo(Vector2.Zero); - this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + this.MoveTo(Vector2.Zero); + this.MoveToOffset(new Vector2(0, 10), 800, Easing.InQuint); + } + else if (Result.IsMiss()) + { + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); - this.RotateTo(0); - this.RotateTo(40, 800, Easing.InQuint); - break; + this.MoveTo(Vector2.Zero); + this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, 800, Easing.InQuint); } this.FadeOutFromOne(800); diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index 15434fcc04..b4686c52f3 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -133,12 +133,11 @@ namespace osu.Game.Rulesets.Judgements case HitResult.None: break; - case HitResult.Miss: - ApplyMissAnimations(); - break; - default: - ApplyHitAnimations(); + if (Result.Type.IsHit()) + ApplyHitAnimations(); + else + ApplyMissAnimations(); break; } diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs index cd1e81046d..93386de483 100644 --- a/osu.Game/Rulesets/Judgements/Judgement.cs +++ b/osu.Game/Rulesets/Judgements/Judgement.cs @@ -11,16 +11,6 @@ namespace osu.Game.Rulesets.Judgements /// public class Judgement { - /// - /// The score awarded for a small bonus. - /// - public const int SMALL_BONUS_SCORE = 10; - - /// - /// The score awarded for a large bonus. - /// - public const int LARGE_BONUS_SCORE = 50; - /// /// The default health increase for a maximum judgement, as a proportion of total health. /// By default, each maximum judgement restores 5% of total health. @@ -83,6 +73,7 @@ namespace osu.Game.Rulesets.Judgements return HitResult.SmallTickMiss; case HitResult.LargeTickHit: + case HitResult.SliderTailHit: return HitResult.LargeTickMiss; default: @@ -91,23 +82,11 @@ namespace osu.Game.Rulesets.Judgements } } - /// - /// The numeric score representation for the maximum achievable result. - /// - public int MaxNumericResult => ToNumericResult(MaxResult); - /// /// The health increase for the maximum achievable result. /// public double MaxHealthIncrease => HealthIncreaseFor(MaxResult); - /// - /// Retrieves the numeric score representation of a . - /// - /// The to find the numeric score representation for. - /// The numeric score representation of . - public int NumericResultFor(JudgementResult result) => ToNumericResult(result.Type); - /// /// Retrieves the numeric health increase of a . /// @@ -126,6 +105,7 @@ namespace osu.Game.Rulesets.Judgements case HitResult.SmallTickMiss: return -DEFAULT_MAX_HEALTH_INCREASE * 0.5; + case HitResult.SliderTailHit: case HitResult.LargeTickHit: return DEFAULT_MAX_HEALTH_INCREASE; @@ -165,41 +145,6 @@ namespace osu.Game.Rulesets.Judgements /// The numeric health increase of . public double HealthIncreaseFor(JudgementResult result) => HealthIncreaseFor(result.Type); - public override string ToString() => $"MaxResult:{MaxResult} MaxScore:{MaxNumericResult}"; - - public static int ToNumericResult(HitResult result) - { - switch (result) - { - default: - return 0; - - case HitResult.SmallTickHit: - return 10; - - case HitResult.LargeTickHit: - return 30; - - case HitResult.Meh: - return 50; - - case HitResult.Ok: - return 100; - - case HitResult.Good: - return 200; - - case HitResult.Great: - // Perfect doesn't actually give more score / accuracy directly. - case HitResult.Perfect: - return 300; - - case HitResult.SmallBonus: - return SMALL_BONUS_SCORE; - - case HitResult.LargeBonus: - return LARGE_BONUS_SCORE; - } - } + public override string ToString() => $"MaxResult:{MaxResult}"; } } diff --git a/osu.Game/Rulesets/Judgements/JudgementResult.cs b/osu.Game/Rulesets/Judgements/JudgementResult.cs index db621b4851..1b915d52b7 100644 --- a/osu.Game/Rulesets/Judgements/JudgementResult.cs +++ b/osu.Game/Rulesets/Judgements/JudgementResult.cs @@ -112,6 +112,6 @@ namespace osu.Game.Rulesets.Judgements RawTime = null; } - public override string ToString() => $"{Type} (Score:{Judgement.NumericResultFor(this)} HP:{Judgement.HealthIncreaseFor(this)} {Judgement})"; + public override string ToString() => $"{Type} ({Judgement})"; } } diff --git a/osu.Game/Rulesets/Mods/IApplicableHealthProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableHealthProcessor.cs new file mode 100644 index 0000000000..be46828069 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableHealthProcessor.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + /// + /// Interface for a that provides its own health processor. + /// + public interface IApplicableHealthProcessor + { + /// + /// Creates the . May be null to use the ruleset default. + /// + HealthProcessor? CreateHealthProcessor(double drainStartTime); + } +} diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 775f6a0ed4..0500b49513 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Reflection; using Newtonsoft.Json; @@ -194,7 +195,7 @@ namespace osu.Game.Rulesets.Mods /// /// Whether all settings in this mod are set to their default state. /// - protected virtual bool UsesDefaultConfiguration => SettingsBindables.All(s => s.IsDefault); + public virtual bool UsesDefaultConfiguration => SettingsBindables.All(s => s.IsDefault); /// /// Creates a copy of this initialised to a default state. @@ -284,7 +285,7 @@ namespace osu.Game.Rulesets.Mods if (!(target is IParseable parseable)) throw new InvalidOperationException($"Bindable type {target.GetType().ReadableName()} is not {nameof(IParseable)}."); - parseable.Parse(source); + parseable.Parse(source, CultureInfo.InvariantCulture); } } diff --git a/osu.Game/Rulesets/Mods/ModBlockFail.cs b/osu.Game/Rulesets/Mods/ModBlockFail.cs deleted file mode 100644 index cdfb36ebbc..0000000000 --- a/osu.Game/Rulesets/Mods/ModBlockFail.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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.Bindables; -using osu.Game.Configuration; -using osu.Game.Screens.Play; - -namespace osu.Game.Rulesets.Mods -{ - public abstract class ModBlockFail : Mod, IApplicableFailOverride, IApplicableToHUD, IReadFromConfig - { - private readonly Bindable showHealthBar = new Bindable(); - - /// - /// We never fail, 'yo. - /// - public bool PerformFail() => false; - - public bool RestartOnFail => false; - - public void ReadFromConfig(OsuConfigManager config) - { - config.BindWith(OsuSetting.ShowHealthDisplayWhenCantFail, showHealthBar); - } - - public void ApplyToHUD(HUDOverlay overlay) - { - overlay.ShowHealthBar.BindTo(showHealthBar); - } - } -} diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index ae661c5f25..7c88a8a588 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -23,14 +23,14 @@ namespace osu.Game.Rulesets.Mods } } - public class ModCinema : ModAutoplay, IApplicableToHUD, IApplicableToPlayer + public class ModCinema : ModAutoplay, IApplicableToHUD, IApplicableToPlayer, IApplicableFailOverride { public override string Name => "Cinema"; public override string Acronym => "CN"; public override IconUsage? Icon => OsuIcon.ModCinema; public override LocalisableString Description => "Watch the video without visual distractions."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModAutoplay)).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) }).ToArray(); public void ApplyToHUD(HUDOverlay overlay) { @@ -45,5 +45,9 @@ namespace osu.Game.Rulesets.Mods player.BreakOverlay.Hide(); } + + public bool PerformFail() => false; + + public bool RestartOnFail => false; } } diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs index 55b16297e2..16cb928bd4 100644 --- a/osu.Game/Rulesets/Mods/ModClassic.cs +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "CL"; - public override double ScoreMultiplier => 1; + public override double ScoreMultiplier => 0.96; public override IconUsage? Icon => FontAwesome.Solid.History; diff --git a/osu.Game/Rulesets/Mods/ModDaycore.cs b/osu.Game/Rulesets/Mods/ModDaycore.cs index 39ebd1fe4c..09b35c249e 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Overlays.Settings; namespace osu.Game.Rulesets.Mods { @@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyReduction; public override LocalisableString Description => "Whoaaaaa..."; - [SettingSource("Speed decrease", "The actual decrease to apply")] + [SettingSource("Speed decrease", "The actual decrease to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(0.75) { MinValue = 0.5, diff --git a/osu.Game/Rulesets/Mods/ModFailCondition.cs b/osu.Game/Rulesets/Mods/ModFailCondition.cs index e671c065cf..0b229766c1 100644 --- a/osu.Game/Rulesets/Mods/ModFailCondition.cs +++ b/osu.Game/Rulesets/Mods/ModFailCondition.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public abstract class ModFailCondition : Mod, IApplicableToHealthProcessor, IApplicableFailOverride { - public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModRelax) }; + public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModCinema) }; [SettingSource("Restart on fail", "Automatically restarts when failed.")] public BindableBool Restart { get; } = new BindableBool(); diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 215fc877dc..dc2ad6f47e 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -56,9 +56,6 @@ namespace osu.Game.Rulesets.Mods public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { Combo.BindTo(scoreProcessor.Combo); - - // Default value of ScoreProcessor's Rank in Flashlight Mod should be SS+ - scoreProcessor.Rank.Value = ScoreRank.XH; } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) @@ -252,7 +249,7 @@ namespace osu.Game.Rulesets.Mods private IUniformBuffer? flashlightParametersBuffer; - public override void Draw(IRenderer renderer) + protected override void Draw(IRenderer renderer) { base.Draw(renderer); diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index 5a8226115f..8b25768575 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -17,8 +17,6 @@ namespace osu.Game.Rulesets.Mods public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { - // Default value of ScoreProcessor's Rank in Hidden Mod should be SS+ - scoreProcessor.Rank.Value = ScoreRank.XH; } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index b519ab4db7..b42927256c 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -14,6 +14,7 @@ using osu.Game.Beatmaps.Timing; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Skinning; @@ -28,7 +29,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => "Uguuuuuuuu..."; - [SettingSource("Speed increase", "The actual increase to apply")] + [SettingSource("Speed increase", "The actual increase to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(1.5) { MinValue = 1.01, diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index 8c61d948a4..cc451772b2 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -2,13 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.Mods { - public abstract class ModNoFail : ModBlockFail + public abstract class ModNoFail : Mod, IApplicableFailOverride, IApplicableToHUD, IReadFromConfig { public override string Name => "No Fail"; public override string Acronym => "NF"; @@ -16,6 +19,25 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyReduction; public override LocalisableString Description => "You can't fail, no matter what."; public override double ScoreMultiplier => 0.5; - public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition) }; + public override Type[] IncompatibleMods => new[] { typeof(ModFailCondition), typeof(ModCinema) }; + + private readonly Bindable showHealthBar = new Bindable(); + + /// + /// We never fail, 'yo. + /// + public bool PerformFail() => false; + + public bool RestartOnFail => false; + + public void ReadFromConfig(OsuConfigManager config) + { + config.BindWith(OsuSetting.ShowHealthDisplayWhenCantFail, showHealthBar); + } + + public void ApplyToHUD(HUDOverlay overlay) + { + overlay.ShowHealthBar.BindTo(showHealthBar); + } } } diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index 6f0bb7ad3b..0ba40ba070 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -28,7 +28,9 @@ namespace osu.Game.Rulesets.Mods } protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) - => result.Type.AffectsAccuracy() + => (isRelevantResult(result.Judgement.MinResult) || isRelevantResult(result.Judgement.MaxResult) || isRelevantResult(result.Type)) && result.Type != result.Judgement.MaxResult; + + private bool isRelevantResult(HitResult result) => result.AffectsAccuracy() || result.AffectsCombo(); } } diff --git a/osu.Game/Rulesets/Mods/ModRelax.cs b/osu.Game/Rulesets/Mods/ModRelax.cs index 49c10339ee..3d672b5ef8 100644 --- a/osu.Game/Rulesets/Mods/ModRelax.cs +++ b/osu.Game/Rulesets/Mods/ModRelax.cs @@ -7,13 +7,13 @@ using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { - public abstract class ModRelax : ModBlockFail + public abstract class ModRelax : Mod { public override string Name => "Relax"; public override string Acronym => "RX"; public override IconUsage? Icon => OsuIcon.ModRelax; public override ModType Type => ModType.Automation; public override double ScoreMultiplier => 0.1; - public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) }; + public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay) }; } } diff --git a/osu.Game/Rulesets/Mods/ModSynesthesia.cs b/osu.Game/Rulesets/Mods/ModSynesthesia.cs index 23cb135c50..9084127f33 100644 --- a/osu.Game/Rulesets/Mods/ModSynesthesia.cs +++ b/osu.Game/Rulesets/Mods/ModSynesthesia.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Synesthesia"; public override string Acronym => "SY"; public override LocalisableString Description => "Colours hit objects based on the rhythm."; - public override double ScoreMultiplier => 1; + public override double ScoreMultiplier => 0.8; public override ModType Type => ModType.Fun; } } diff --git a/osu.Game/Rulesets/Mods/RateAdjustModHelper.cs b/osu.Game/Rulesets/Mods/RateAdjustModHelper.cs index ffd4de0e90..8bc481921f 100644 --- a/osu.Game/Rulesets/Mods/RateAdjustModHelper.cs +++ b/osu.Game/Rulesets/Mods/RateAdjustModHelper.cs @@ -32,9 +32,9 @@ namespace osu.Game.Rulesets.Mods value -= 1; if (SpeedChange.Value >= 1) - value /= 5; - - return 1 + value; + return 1 + value / 5; + else + return 0.6 + value; } } diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 1daaa24d57..bce28361cb 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -599,7 +599,9 @@ namespace osu.Game.Rulesets.Objects.Drawables float balanceAdjustAmount = positionalHitsoundsLevel.Value * 2; double returnedValue = balanceAdjustAmount * (position - 0.5f); - return returnedValue; + // Rounded to reduce the overhead of audio adjustments (which are currently bindable heavy). + // Balance is very hard to perceive in small increments anyways. + return Math.Round(returnedValue, 2); } /// diff --git a/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs b/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs index 53cf835248..2a5a11161b 100644 --- a/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs +++ b/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs @@ -57,5 +57,40 @@ namespace osu.Game.Rulesets.Objects.Legacy return (float)(1.0f - 0.7f * IBeatmapDifficultyInfo.DifficultyRange(circleSize)) / 2 * (applyFudge ? broken_gamefield_rounding_allowance : 1); } + + public static int CalculateDifficultyPeppyStars(BeatmapDifficulty difficulty, int objectCount, int drainLength) + { + /* + * WARNING: DO NOT TOUCH IF YOU DO NOT KNOW WHAT YOU ARE DOING + * + * It so happens that in stable, due to .NET Framework internals, float math would be performed + * using x87 registers and opcodes. + * .NET (Core) however uses SSE instructions on 32- and 64-bit words. + * x87 registers are _80 bits_ wide. Which is notably wider than _both_ float and double. + * Therefore, on a significant number of beatmaps, the rounding would not produce correct values. + * + * Thus, to crudely - but, seemingly *mostly* accurately, after checking across all ranked maps - emulate this, + * use `decimal`, which is slow, but has bigger precision than `double`. + * At the time of writing, there is _one_ ranked exception to this - namely https://osu.ppy.sh/beatmapsets/1156087#osu/2625853 - + * but it is considered an "acceptable casualty", since in that case scores aren't inflated by _that_ much compared to others. + */ + + decimal objectToDrainRatio = drainLength != 0 + ? Math.Clamp((decimal)objectCount / drainLength * 8, 0, 16) + : 16; + + /* + * Notably, THE `double` CASTS BELOW ARE IMPORTANT AND MUST REMAIN. + * Their goal is to trick the compiler / runtime into NOT promoting from single-precision float, as doing so would prompt it + * to attempt to "silently" fix the single-precision values when converting to decimal, + * which is NOT what the x87 FPU does. + */ + + decimal drainRate = (decimal)(double)difficulty.DrainRate; + decimal overallDifficulty = (decimal)(double)difficulty.OverallDifficulty; + decimal circleSize = (decimal)(double)difficulty.CircleSize; + + return (int)Math.Round((drainRate + overallDifficulty + circleSize + objectToDrainRatio) / 38 * 5); + } } } diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index e9a192669f..dc71608132 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Objects /// public readonly Bindable ExpectedDistance = new Bindable(); - public bool HasValidLength => Distance > 0; + public bool HasValidLength => Precision.DefinitelyBigger(Distance, 0); /// /// The control points of the path. diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index f8aa6c9f57..37a35fd3ae 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -377,6 +377,17 @@ namespace osu.Game.Rulesets /// The display name. public virtual LocalisableString GetDisplayNameForHitResult(HitResult result) => result.GetLocalisableDescription(); + /// + /// Applies changes to difficulty attributes for presenting to a user a rough estimate of how rate adjust mods affect difficulty. + /// Importantly, this should NOT BE USED FOR ANY CALCULATIONS. + /// + /// It is also not always correct, and arguably is never correct depending on your frame of mind. + /// + /// >The that will be adjusted. + /// The rate adjustment multiplier from mods. For example 1.5 for DT. + /// The adjusted difficulty attributes. + public virtual BeatmapDifficulty GetRateAdjustedDisplayDifficulty(IBeatmapDifficultyInfo difficulty, double rate) => new BeatmapDifficulty(difficulty); + /// /// Creates ruleset-specific beatmap filter criteria to be used on the song select screen. /// diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs index a2fc23ac2e..629a84ea62 100644 --- a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -61,7 +61,8 @@ namespace osu.Game.Rulesets.Scoring /// protected readonly double DrainLenience; - private readonly List<(double time, double health)> healthIncreases = new List<(double, double)>(); + private readonly List healthIncreases = new List(); + private double gameplayEndTime; private double targetMinimumHealth; @@ -133,8 +134,12 @@ namespace osu.Game.Rulesets.Scoring { base.ApplyResultInternal(result); - if (!result.Type.IsBonus()) - healthIncreases.Add((result.HitObject.GetEndTime() + result.TimeOffset, GetHealthIncreaseFor(result))); + if (IsSimulating && !result.Type.IsBonus()) + { + healthIncreases.Add(new HealthIncrease( + result.HitObject.GetEndTime() + result.TimeOffset, + GetHealthIncreaseFor(result))); + } } protected override void Reset(bool storeResults) @@ -165,8 +170,8 @@ namespace osu.Game.Rulesets.Scoring for (int i = 0; i < healthIncreases.Count; i++) { - double currentTime = healthIncreases[i].time; - double lastTime = i > 0 ? healthIncreases[i - 1].time : DrainStartTime; + double currentTime = healthIncreases[i].Time; + double lastTime = i > 0 ? healthIncreases[i - 1].Time : DrainStartTime; while (currentBreak < Beatmap.Breaks.Count && Beatmap.Breaks[currentBreak].EndTime <= currentTime) { @@ -180,7 +185,7 @@ namespace osu.Game.Rulesets.Scoring // Apply health adjustments currentHealth -= (currentTime - lastTime) * result; lowestHealth = Math.Min(lowestHealth, currentHealth); - currentHealth = Math.Min(1, currentHealth + healthIncreases[i].health); + currentHealth = Math.Min(1, currentHealth + healthIncreases[i].Amount); // Common scenario for when the drain rate is definitely too harsh if (lowestHealth < 0) @@ -198,5 +203,7 @@ namespace osu.Game.Rulesets.Scoring return result; } + + private record struct HealthIncrease(double Time, double Amount); } } diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 6380b73558..f307344347 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -86,6 +86,7 @@ namespace osu.Game.Rulesets.Scoring /// Indicates a large tick miss. /// [EnumMember(Value = "large_tick_miss")] + [Description("-")] [Order(10)] LargeTickMiss, @@ -117,6 +118,7 @@ namespace osu.Game.Rulesets.Scoring /// Indicates a miss that should be ignored for scoring purposes. /// [EnumMember(Value = "ignore_miss")] + [Description("-")] [Order(13)] IgnoreMiss, @@ -137,11 +139,21 @@ namespace osu.Game.Rulesets.Scoring [Order(15)] ComboBreak, + /// + /// A special judgement similar to that's used to increase the valuation of the final tick of a slider. + /// + [EnumMember(Value = "slider_tail_hit")] + [Order(16)] + SliderTailHit, + /// /// A special result used as a padding value for legacy rulesets. It is a hit type and affects combo, but does not affect the base score (does not affect accuracy). + /// + /// DO NOT USE FOR ANYTHING EVER. /// /// - /// DO NOT USE. + /// This is used when dealing with legacy scores, which historically only have counts stored for 300/100/50/miss. + /// For these scores, we pad the hit statistics with `LegacyComboIncrease` to meet the correct max combo for the score. /// [EnumMember(Value = "legacy_combo_increase")] [Order(99)] @@ -183,6 +195,7 @@ namespace osu.Game.Rulesets.Scoring case HitResult.LargeTickMiss: case HitResult.LegacyComboIncrease: case HitResult.ComboBreak: + case HitResult.SliderTailHit: return true; default: @@ -241,6 +254,7 @@ namespace osu.Game.Rulesets.Scoring case HitResult.LargeTickMiss: case HitResult.SmallTickHit: case HitResult.SmallTickMiss: + case HitResult.SliderTailHit: return true; default: @@ -264,9 +278,34 @@ namespace osu.Game.Rulesets.Scoring } } + /// + /// Whether a represents a miss of any type. + /// + /// + /// Of note, both and return for . + /// + public static bool IsMiss(this HitResult result) + { + switch (result) + { + case HitResult.IgnoreMiss: + case HitResult.Miss: + case HitResult.SmallTickMiss: + case HitResult.LargeTickMiss: + case HitResult.ComboBreak: + return true; + + default: + return false; + } + } + /// /// Whether a represents a successful hit. /// + /// + /// Of note, both and return for . + /// public static bool IsHit(this HitResult result) { switch (result) @@ -299,6 +338,9 @@ namespace osu.Game.Rulesets.Scoring case HitResult.ComboBreak: return true; + case HitResult.SliderTailHit: + return true; + default: // Note that IgnoreHit and IgnoreMiss are excluded as they do not affect score. return result >= HitResult.Miss && result < HitResult.IgnoreMiss; @@ -353,6 +395,9 @@ namespace osu.Game.Rulesets.Scoring if (minResult == HitResult.IgnoreMiss) return; + if (maxResult == HitResult.SliderTailHit && minResult != HitResult.LargeTickMiss) + throw new ArgumentOutOfRangeException(nameof(minResult), $"{HitResult.LargeTickMiss} is the only valid minimum result for a {maxResult} judgement."); + if (maxResult == HitResult.LargeTickHit && minResult != HitResult.LargeTickMiss) throw new ArgumentOutOfRangeException(nameof(minResult), $"{HitResult.LargeTickMiss} is the only valid minimum result for a {maxResult} judgement."); diff --git a/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs b/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs index 97ccf787af..7d69069455 100644 --- a/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs +++ b/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs @@ -29,12 +29,12 @@ namespace osu.Game.Rulesets.Scoring.Legacy public float OverallDifficulty { get; set; } /// - /// The count of hitcircles in the beatmap. + /// The number of hitobjects in the beatmap with a distinct end time. /// /// - /// When converting from osu! ruleset beatmaps, this is equivalent to the sum of sliders and spinners in the beatmap. + /// Canonically, these are hitobjects are either sliders or spinners. /// - public int CircleCount { get; set; } + public int EndTimeObjectCount { get; set; } /// /// The total count of hitobjects in the beatmap. @@ -46,22 +46,24 @@ namespace osu.Game.Rulesets.Scoring.Legacy double IBeatmapDifficultyInfo.SliderMultiplier => 0; double IBeatmapDifficultyInfo.SliderTickRate => 0; - public static LegacyBeatmapConversionDifficultyInfo FromAPIBeatmap(APIBeatmap apiBeatmap) => new LegacyBeatmapConversionDifficultyInfo - { - SourceRuleset = apiBeatmap.Ruleset, - CircleSize = apiBeatmap.CircleSize, - OverallDifficulty = apiBeatmap.OverallDifficulty, - CircleCount = apiBeatmap.CircleCount, - TotalObjectCount = apiBeatmap.SliderCount + apiBeatmap.SpinnerCount + apiBeatmap.CircleCount - }; + public static LegacyBeatmapConversionDifficultyInfo FromAPIBeatmap(APIBeatmap apiBeatmap) => FromBeatmapInfo(apiBeatmap); public static LegacyBeatmapConversionDifficultyInfo FromBeatmap(IBeatmap beatmap) => new LegacyBeatmapConversionDifficultyInfo { SourceRuleset = beatmap.BeatmapInfo.Ruleset, CircleSize = beatmap.Difficulty.CircleSize, OverallDifficulty = beatmap.Difficulty.OverallDifficulty, - CircleCount = beatmap.HitObjects.Count(h => h is not IHasDuration), + EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration), TotalObjectCount = beatmap.HitObjects.Count }; + + public static LegacyBeatmapConversionDifficultyInfo FromBeatmapInfo(IBeatmapInfo beatmapInfo) => new LegacyBeatmapConversionDifficultyInfo + { + SourceRuleset = beatmapInfo.Ruleset, + CircleSize = beatmapInfo.Difficulty.CircleSize, + OverallDifficulty = beatmapInfo.Difficulty.OverallDifficulty, + EndTimeObjectCount = beatmapInfo.EndTimeObjectCount, + TotalObjectCount = beatmapInfo.TotalObjectCount + }; } } diff --git a/osu.Game/Rulesets/Scoring/Legacy/LegacyScoreAttributes.cs b/osu.Game/Rulesets/Scoring/Legacy/LegacyScoreAttributes.cs index 47ab68bf88..6f6740c641 100644 --- a/osu.Game/Rulesets/Scoring/Legacy/LegacyScoreAttributes.cs +++ b/osu.Game/Rulesets/Scoring/Legacy/LegacyScoreAttributes.cs @@ -19,5 +19,15 @@ namespace osu.Game.Rulesets.Scoring.Legacy /// A ratio of standardised score to legacy score for the bonus part of total score. /// public double BonusScoreRatio; + + /// + /// The bonus portion of the legacy (ScoreV1) total score. + /// + public int BonusScore; + + /// + /// The max combo of the legacy (ScoreV1) total score. + /// + public int MaxCombo; } } diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 92336b2c21..14fa928224 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -21,6 +21,14 @@ namespace osu.Game.Rulesets.Scoring { public partial class ScoreProcessor : JudgementProcessor { + /// + /// The exponent applied to combo in the default implementation of . + /// + /// + /// If a custom implementation overrides this may not be relevant. + /// + public const double COMBO_EXPONENT = 0.5; + public const double MAX_SCORE = 1000000; private const double accuracy_cutoff_x = 1; @@ -78,7 +86,9 @@ namespace osu.Game.Rulesets.Scoring /// /// The current rank. /// - public readonly Bindable Rank = new Bindable(ScoreRank.X); + public IBindable Rank => rank; + + private readonly Bindable rank = new Bindable(ScoreRank.X); /// /// The highest combo achieved by this score. @@ -159,14 +169,14 @@ namespace osu.Game.Rulesets.Scoring if (!beatmapApplied) throw new InvalidOperationException($"Cannot access maximum statistics before calling {nameof(ApplyBeatmap)}."); - return new Dictionary(maximumResultCounts); + return new Dictionary(MaximumResultCounts); } } private bool beatmapApplied; - private readonly Dictionary scoreResultCounts = new Dictionary(); - private readonly Dictionary maximumResultCounts = new Dictionary(); + protected readonly Dictionary ScoreResultCounts = new Dictionary(); + protected readonly Dictionary MaximumResultCounts = new Dictionary(); private readonly List hitEvents = new List(); private HitObject? lastHitObject; @@ -178,9 +188,13 @@ namespace osu.Game.Rulesets.Scoring Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => { - Rank.Value = RankFromAccuracy(accuracy.NewValue); + // Once failed, we shouldn't update the rank anymore. + if (rank.Value == ScoreRank.F) + return; + + rank.Value = RankFromAccuracy(accuracy.NewValue); foreach (var mod in Mods.Value.OfType()) - Rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); + rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); }; Mods.ValueChanged += mods => @@ -208,10 +222,7 @@ namespace osu.Game.Rulesets.Scoring if (result.FailedAtJudgement) return; - scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; - - if (!result.Type.IsScorable()) - return; + ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) + 1; if (result.Type.IncreasesCombo()) Combo.Value++; @@ -220,16 +231,18 @@ namespace osu.Game.Rulesets.Scoring result.ComboAfterJudgement = Combo.Value; - if (result.Type.AffectsAccuracy()) + if (result.Judgement.MaxResult.AffectsAccuracy()) { - currentMaximumBaseScore += Judgement.ToNumericResult(result.Judgement.MaxResult); - currentBaseScore += Judgement.ToNumericResult(result.Type); + currentMaximumBaseScore += GetBaseScoreForResult(result.Judgement.MaxResult); currentAccuracyJudgementCount++; } + if (result.Type.AffectsAccuracy()) + currentBaseScore += GetBaseScoreForResult(result.Type); + if (result.Type.IsBonus()) currentBonusPortion += GetBonusScoreChange(result); - else + else if (result.Type.IsScorable()) currentComboPortion += GetComboScoreChange(result); ApplyScoreChange(result); @@ -265,21 +278,20 @@ namespace osu.Game.Rulesets.Scoring if (result.FailedAtJudgement) return; - scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; + ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) - 1; - if (!result.Type.IsScorable()) - return; - - if (result.Type.AffectsAccuracy()) + if (result.Judgement.MaxResult.AffectsAccuracy()) { - currentMaximumBaseScore -= Judgement.ToNumericResult(result.Judgement.MaxResult); - currentBaseScore -= Judgement.ToNumericResult(result.Type); + currentMaximumBaseScore -= GetBaseScoreForResult(result.Judgement.MaxResult); currentAccuracyJudgementCount--; } + if (result.Type.AffectsAccuracy()) + currentBaseScore -= GetBaseScoreForResult(result.Type); + if (result.Type.IsBonus()) currentBonusPortion -= GetBonusScoreChange(result); - else + else if (result.Type.IsScorable()) currentComboPortion -= GetComboScoreChange(result); RemoveScoreChange(result); @@ -291,9 +303,54 @@ namespace osu.Game.Rulesets.Scoring updateScore(); } - protected virtual double GetBonusScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Type); + /// + /// Gets the final score change to be applied to the bonus portion of the score. + /// + /// The judgement result. + protected virtual double GetBonusScoreChange(JudgementResult result) => GetBaseScoreForResult(result.Type); - protected virtual double GetComboScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Type) * (1 + result.ComboAfterJudgement / 10d); + /// + /// Gets the final score change to be applied to the combo portion of the score. + /// + /// The judgement result. + protected virtual double GetComboScoreChange(JudgementResult result) => GetBaseScoreForResult(result.Judgement.MaxResult) * Math.Pow(result.ComboAfterJudgement, COMBO_EXPONENT); + + public virtual int GetBaseScoreForResult(HitResult result) + { + switch (result) + { + default: + return 0; + + case HitResult.SmallTickHit: + return 10; + + case HitResult.LargeTickHit: + return 30; + + case HitResult.SliderTailHit: + return 150; + + case HitResult.Meh: + return 50; + + case HitResult.Ok: + return 100; + + case HitResult.Good: + return 200; + + case HitResult.Great: + case HitResult.Perfect: // Perfect doesn't actually give more score / accuracy directly. + return 300; + + case HitResult.SmallBonus: + return 10; + + case HitResult.LargeBonus: + return 50; + } + } protected virtual void ApplyScoreChange(JudgementResult result) { @@ -317,8 +374,8 @@ namespace osu.Game.Rulesets.Scoring protected virtual double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { - return 700000 * comboProgress + - 300000 * Math.Pow(Accuracy.Value, 10) * accuracyProgress + + return 500000 * Accuracy.Value * comboProgress + + 500000 * Math.Pow(Accuracy.Value, 5) * accuracyProgress + bonusPortion; } @@ -343,13 +400,13 @@ namespace osu.Game.Rulesets.Scoring maximumComboPortion = currentComboPortion; maximumAccuracyJudgementCount = currentAccuracyJudgementCount; - maximumResultCounts.Clear(); - maximumResultCounts.AddRange(scoreResultCounts); + MaximumResultCounts.Clear(); + MaximumResultCounts.AddRange(ScoreResultCounts); MaximumTotalScore = TotalScore.Value; } - scoreResultCounts.Clear(); + ScoreResultCounts.Clear(); currentBaseScore = 0; currentMaximumBaseScore = 0; @@ -360,8 +417,7 @@ namespace osu.Game.Rulesets.Scoring TotalScore.Value = 0; Accuracy.Value = 1; Combo.Value = 0; - Rank.Disabled = false; - Rank.Value = ScoreRank.X; + rank.Value = ScoreRank.X; HighestCombo.Value = 0; } @@ -379,10 +435,10 @@ namespace osu.Game.Rulesets.Scoring score.MaximumStatistics.Clear(); foreach (var result in HitResultExtensions.ALL_TYPES) - score.Statistics[result] = scoreResultCounts.GetValueOrDefault(result); + score.Statistics[result] = ScoreResultCounts.GetValueOrDefault(result); foreach (var result in HitResultExtensions.ALL_TYPES) - score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result); + score.MaximumStatistics[result] = MaximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. score.TotalScore = TotalScore.Value; @@ -397,7 +453,7 @@ namespace osu.Game.Rulesets.Scoring return; score.Passed = false; - Rank.Value = ScoreRank.F; + rank.Value = ScoreRank.F; PopulateScore(score); } @@ -413,8 +469,8 @@ namespace osu.Game.Rulesets.Scoring HighestCombo.Value = frame.Header.MaxCombo; TotalScore.Value = frame.Header.TotalScore; - scoreResultCounts.Clear(); - scoreResultCounts.AddRange(frame.Header.Statistics); + ScoreResultCounts.Clear(); + ScoreResultCounts.AddRange(frame.Header.Statistics); SetScoreProcessorStatistics(frame.Header.ScoreProcessorStatistics); @@ -522,7 +578,7 @@ namespace osu.Game.Rulesets.Scoring /// /// /// Used to compute accuracy. - /// See: and . + /// See: and . /// [Key(0)] public double BaseScore { get; set; } diff --git a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs index d34edf7bdf..2c5b91f10f 100644 --- a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs +++ b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs @@ -35,12 +35,16 @@ namespace osu.Game.Scoring.Legacy [JsonProperty("maximum_statistics")] public Dictionary MaximumStatistics { get; set; } = new Dictionary(); + [JsonProperty("client_version")] + public string ClientVersion = string.Empty; + public static LegacyReplaySoloScoreInfo FromScore(ScoreInfo score) => new LegacyReplaySoloScoreInfo { OnlineID = score.OnlineID, Mods = score.APIMods, Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value), MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value), + ClientVersion = score.ClientVersion, }; } } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index c5e6e3bcce..ed11691674 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -125,6 +125,7 @@ namespace osu.Game.Scoring.Legacy score.ScoreInfo.Statistics = readScore.Statistics; score.ScoreInfo.MaximumStatistics = readScore.MaximumStatistics; score.ScoreInfo.Mods = readScore.Mods.Select(m => m.ToMod(currentRuleset)).ToArray(); + score.ScoreInfo.ClientVersion = readScore.ClientVersion; }); } } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index 872f09dda6..389b20b5c8 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -13,6 +13,7 @@ using osu.Game.Extensions; using osu.Game.IO.Legacy; using osu.Game.IO.Serialization; using osu.Game.Replays.Legacy; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; using SharpCompress.Compressors.LZMA; @@ -31,9 +32,20 @@ namespace osu.Game.Scoring.Legacy /// 30000002: Score stored to replay calculated using the Score V2 algorithm. Legacy scores on this version are candidate to Score V1 -> V2 conversion. /// 30000003: First version after converting legacy total score to standardised. /// 30000004: Fixed mod multipliers during legacy score conversion. Reconvert all scores. + /// 30000005: Introduce combo exponent in the osu! gamemode. Reconvert all scores. + /// 30000006: Fix edge cases in conversion after combo exponent introduction that lead to NaNs. Reconvert all scores. + /// 30000007: Adjust osu!mania combo and accuracy portions and judgement scoring values. Reconvert all scores. + /// 30000008: Add accuracy conversion. Reconvert all scores. + /// 30000009: Fix edge cases in conversion for scores which have 0.0x mod multiplier on stable. Reconvert all scores. + /// 30000010: Fix mania score V1 conversion using score V1 accuracy rather than V2 accuracy. Reconvert all scores. + /// 30000011: Re-do catch scoring to mirror stable Score V2 as closely as feasible. Reconvert all scores. + /// + /// 30000012: Fix incorrect total score conversion on selected beatmaps after implementing the more correct + /// method. Reconvert all scores. + /// /// /// - public const int LATEST_VERSION = 30000004; + public const int LATEST_VERSION = 30000012; /// /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays. diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index b216c0897e..8e28707107 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -17,7 +17,6 @@ using osu.Game.Scoring.Legacy; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using Realms; @@ -107,7 +106,7 @@ namespace osu.Game.Scoring else if (model.IsLegacyScore) { model.LegacyTotalScore = model.TotalScore; - model.TotalScore = StandardisedScoreMigrationTools.ConvertFromLegacyTotalScore(model, beatmaps()); + StandardisedScoreMigrationTools.UpdateFromLegacy(model, beatmaps()); } } @@ -125,13 +124,14 @@ namespace osu.Game.Scoring var beatmap = score.BeatmapInfo!.Detach(); var ruleset = score.Ruleset.Detach(); var rulesetInstance = ruleset.CreateInstance(); + var scoreProcessor = rulesetInstance.CreateScoreProcessor(); Debug.Assert(rulesetInstance != null); // Populate the maximum statistics. HitResult maxBasicResult = rulesetInstance.GetHitResults() .Select(h => h.result) - .Where(h => h.IsBasic()).MaxBy(Judgement.ToNumericResult); + .Where(h => h.IsBasic()).MaxBy(scoreProcessor.GetBaseScoreForResult); foreach ((HitResult result, int count) in score.Statistics) { diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 5545ba552e..32e4bbbf29 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -46,6 +46,12 @@ namespace osu.Game.Scoring /// public BeatmapInfo? BeatmapInfo { get; set; } + /// + /// The version of the client this score was set using. + /// Sourced from at the point of score submission. + /// + public string ClientVersion { get; set; } = string.Empty; + /// /// The at the point in time when the score was set. /// @@ -201,6 +207,7 @@ namespace osu.Game.Scoring clone.Statistics = new Dictionary(clone.Statistics); clone.MaximumStatistics = new Dictionary(clone.MaximumStatistics); + clone.HitEvents = new List(clone.HitEvents); // Ensure we have fresh mods to avoid any references (ie. after gameplay). clone.clearAllMods(); @@ -343,6 +350,7 @@ namespace osu.Game.Scoring { case HitResult.SmallTickHit: case HitResult.LargeTickHit: + case HitResult.SliderTailHit: case HitResult.LargeBonus: case HitResult.SmallBonus: if (MaximumStatistics.TryGetValue(r.result, out int count) && count > 0) diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs index 5c77672d90..0e125d0ec0 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs @@ -51,7 +51,7 @@ namespace osu.Game.Screens.Edit.Components.Menus Size = new Vector2(26), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Icon = HexaconsIcons.Editor, + Icon = OsuIcon.EditCircle, }, text = new TextFlowContainer { diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index c7c7c4aa83..4fba798a26 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -225,7 +225,7 @@ namespace osu.Game.Screens.Edit.Compose.Components protected virtual IEnumerable CreateTernaryButtons() { //TODO: this should only be enabled (visible?) for rulesets that provide combo-supporting HitObjects. - yield return new TernaryButton(NewCombo, "New combo", () => new SpriteIcon { Icon = FontAwesome.Regular.DotCircle }); + yield return new TernaryButton(NewCombo, "New combo", () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }); foreach (var kvp in SelectionHandler.SelectionSampleStates) yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => getIconForSample(kvp.Key)); @@ -272,10 +272,10 @@ namespace osu.Game.Screens.Edit.Compose.Components return new SpriteIcon { Icon = FontAwesome.Solid.Hands }; case HitSampleInfo.HIT_WHISTLE: - return new SpriteIcon { Icon = FontAwesome.Solid.Bullhorn }; + return new SpriteIcon { Icon = OsuIcon.EditorWhistle }; case HitSampleInfo.HIT_FINISH: - return new SpriteIcon { Icon = FontAwesome.Solid.DrumSteelpan }; + return new SpriteIcon { Icon = OsuIcon.EditorFinish }; } return null; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs index 024749a701..5270162189 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs @@ -62,6 +62,9 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override bool OnDragStart(DragStartEvent e) { + if (e.Button != MouseButton.Left) + return false; + if (rotationHandler == null) return false; rotationHandler.Begin(); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 75de15fe56..a2704e550c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -141,11 +141,29 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline waveformOpacity = config.GetBindable(OsuSetting.EditorWaveformOpacity); track.BindTo(editorClock.Track); - track.BindValueChanged(_ => waveform.Waveform = beatmap.Value.Waveform, true); + track.BindValueChanged(_ => + { + waveform.Waveform = beatmap.Value.Waveform; + Scheduler.AddOnce(applyVisualOffset, beatmap); + }, true); Zoom = (float)(defaultTimelineZoom * editorBeatmap.BeatmapInfo.TimelineZoom); } + private void applyVisualOffset(IBindable beatmap) + { + waveform.RelativePositionAxes = Axes.X; + + if (beatmap.Value.Track.Length > 0) + waveform.X = -(float)(Editor.WAVEFORM_VISUAL_OFFSET / beatmap.Value.Track.Length); + else + { + // sometimes this can be the case immediately after a track switch. + // reschedule with the hope that the track length eventually populates. + Scheduler.AddOnce(applyVisualOffset, beatmap); + } + } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 77afad2d4f..47dc3fb82e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -409,7 +409,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline double lengthOfOneRepeat = repeatHitObject.Duration / (repeatHitObject.RepeatCount + 1); int proposedCount = Math.Max(0, (int)Math.Round(proposedDuration / lengthOfOneRepeat) - 1); - if (proposedCount == repeatHitObject.RepeatCount || lengthOfOneRepeat == 0) + if (proposedCount == repeatHitObject.RepeatCount || Precision.AlmostEquals(lengthOfOneRepeat, 0)) return; repeatHitObject.RepeatCount = proposedCount; diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index a6c18bdf0e..c1f6c02301 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -60,6 +60,19 @@ namespace osu.Game.Screens.Edit [Cached] public partial class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider { + /// + /// An offset applied to waveform visuals to align them with expectations. + /// + /// + /// Historically, osu! beatmaps have an assumption of full system latency baked in. + /// This comes from a culmination of stable's platform offset, average hardware playback + /// latency, and users having their universal offsets tweaked to previous beatmaps. + /// + /// Coming to this value involved running various tests with existing users / beatmaps. + /// This included both visual and audible comparisons. Ballpark confidence is ≈2 ms. + /// + public const float WAVEFORM_VISUAL_OFFSET = 20; + public override float BackgroundParallaxAmount => 0.1f; public override bool AllowBackButton => false; diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs index b79d71b42b..e5dc540b06 100644 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -46,15 +47,42 @@ namespace osu.Game.Screens.Edit }); } - protected void SetSelectedRow(object? item) + protected int GetIndexForObject(object? item) { + for (int i = 0; i < BackgroundFlow.Count; i++) + { + if (BackgroundFlow[i].Item == item) + return i; + } + + return -1; + } + + protected virtual bool SetSelectedRow(object? item) + { + bool foundSelection = false; + foreach (var b in BackgroundFlow) { b.Selected = ReferenceEquals(b.Item, item); if (b.Selected) + { + Debug.Assert(!foundSelection); OnRowSelected?.Invoke(b); + foundSelection = true; + } } + + return foundSelection; + } + + protected object? GetObjectAtIndex(int index) + { + if (index < 0 || index > BackgroundFlow.Count - 1) + return null; + + return BackgroundFlow[index].Item; } protected override Drawable CreateHeader(int index, TableColumn? column) => new HeaderText(column?.Header ?? default); diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs index 93448c4394..022da36abc 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs @@ -80,7 +80,7 @@ namespace osu.Game.Screens.Edit.Setup { Title = EditorSetupStrings.BeatmapSetup.ToLower(); Description = EditorSetupStrings.BeatmapSetupDescription; - Icon = HexaconsIcons.Social; + Icon = OsuIcon.Beatmap; } } diff --git a/osu.Game/Screens/Edit/Timing/ControlPointList.cs b/osu.Game/Screens/Edit/Timing/ControlPointList.cs index 22e37b9efb..7cd1dbc630 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointList.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointList.cs @@ -109,8 +109,13 @@ namespace osu.Game.Screens.Edit.Timing controlPointGroups.BindTo(Beatmap.ControlPointInfo.Groups); controlPointGroups.BindCollectionChanged((_, _) => { - table.ControlGroups = controlPointGroups; - changeHandler?.SaveState(); + // This callback can happen many times in a change operation. It gets expensive. + // We really should be handling the `CollectionChanged` event properly. + Scheduler.AddOnce(() => + { + table.ControlGroups = controlPointGroups; + changeHandler?.SaveState(); + }); }, true); table.OnRowSelected += drawable => scroll.ScrollIntoView(drawable); diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index b078e3fa44..7a27056da3 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -21,7 +21,7 @@ namespace osu.Game.Screens.Edit.Timing public partial class ControlPointTable : EditorTable { [Resolved] - private Bindable selectedGroup { get; set; } = null!; + private Bindable selectedGroup { get; set; } = null!; [Resolved] private EditorClock clock { get; set; } = null!; @@ -32,6 +32,8 @@ namespace osu.Game.Screens.Edit.Timing { set { + int selectedIndex = GetIndexForObject(selectedGroup.Value); + Content = null; BackgroundFlow.Clear(); @@ -44,7 +46,7 @@ namespace osu.Game.Screens.Edit.Timing { Action = () => { - selectedGroup.Value = group; + SetSelectedRow(group); clock.SeekSmoothlyTo(group.Time); } }); @@ -53,7 +55,16 @@ namespace osu.Game.Screens.Edit.Timing Columns = createHeaders(); Content = value.Select(createContent).ToArray().ToRectangular(); - updateSelectedGroup(); + // Attempt to retain selection. + if (SetSelectedRow(selectedGroup.Value)) + return; + + // Some operations completely obliterate references, so best-effort reselect based on index. + if (SetSelectedRow(GetObjectAtIndex(selectedIndex))) + return; + + // Selection could not be retained. + selectedGroup.Value = null; } } @@ -61,10 +72,18 @@ namespace osu.Game.Screens.Edit.Timing { base.LoadComplete(); - selectedGroup.BindValueChanged(_ => updateSelectedGroup(), true); + // Handle external selections. + selectedGroup.BindValueChanged(g => SetSelectedRow(g.NewValue), true); } - private void updateSelectedGroup() => SetSelectedRow(selectedGroup.Value); + protected override bool SetSelectedRow(object? item) + { + if (!base.SetSelectedRow(item)) + return false; + + selectedGroup.Value = item as ControlPointGroup; + return true; + } private TableColumn[] createHeaders() { diff --git a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs index eabe9b9f64..151d469415 100644 --- a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs @@ -103,7 +103,7 @@ namespace osu.Game.Screens.Edit.Timing break; default: - slider.Current.Parse(t.Text); + slider.Current.Parse(t.Text, CultureInfo.CurrentCulture); break; } } diff --git a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs index 856bc7c303..45213b7bdb 100644 --- a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs @@ -219,12 +219,12 @@ namespace osu.Game.Screens.Edit.Timing // offset to the required beat index. double time = selectedGroupStartTime + index * timingPoint.BeatLength; - float offset = (float)(time - visible_width / 2) / trackLength * scale; + float offset = (float)(time - visible_width / 2 + Editor.WAVEFORM_VISUAL_OFFSET) / trackLength * scale; row.Alpha = time < selectedGroupStartTime || time > selectedGroupEndTime ? 0.2f : 1; row.WaveformOffsetTo(-offset, animated); row.WaveformScale = new Vector2(scale, 1); - row.BeatIndex = (int)Math.Floor(index); + row.BeatIndex = (int)Math.Round(index); index++; } diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index 372cfe748e..4dba512cbd 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -21,8 +21,6 @@ namespace osu.Game.Screens { public partial class Loader : StartupScreen { - private bool showDisclaimer; - public Loader() { ValidForResume = false; @@ -35,13 +33,7 @@ namespace osu.Game.Screens private LoadingSpinner spinner; private ScheduledDelegate spinnerShow; - protected virtual OsuScreen CreateLoadableScreen() - { - if (showDisclaimer) - return new Disclaimer(getIntroSequence()); - - return getIntroSequence(); - } + protected virtual OsuScreen CreateLoadableScreen() => getIntroSequence(); private IntroScreen getIntroSequence() { @@ -107,9 +99,8 @@ namespace osu.Game.Screens } [BackgroundDependencyLoader] - private void load(OsuGameBase game, OsuConfigManager config) + private void load(OsuConfigManager config) { - showDisclaimer = game.IsDeployedBuild; introSequence = config.Get(OsuSetting.IntroSequence); } diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index ca5bef985e..b2b3fbd626 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -13,7 +13,6 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; @@ -103,8 +102,8 @@ namespace osu.Game.Screens.Menu buttonArea.AddRange(new Drawable[] { - new MainMenuButton(ButtonSystemStrings.Settings, string.Empty, FontAwesome.Solid.Cog, new Color4(85, 85, 85, 255), () => OnSettings?.Invoke(), -WEDGE_WIDTH, Key.O), - backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.LeftCircle, new Color4(51, 58, 94, 255), () => State = ButtonSystemState.TopLevel, + new MainMenuButton(ButtonSystemStrings.Settings, string.Empty, OsuIcon.Settings, new Color4(85, 85, 85, 255), () => OnSettings?.Invoke(), -WEDGE_WIDTH, Key.O), + backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.PrevCircle, new Color4(51, 58, 94, 255), () => State = ButtonSystemState.TopLevel, -WEDGE_WIDTH) { VisibleStateMin = ButtonSystemState.Play, @@ -128,18 +127,18 @@ namespace osu.Game.Screens.Menu [BackgroundDependencyLoader] private void load(AudioManager audio, IdleTracker? idleTracker, GameHost host) { - buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Solo, @"button-default-select", FontAwesome.Solid.User, new Color4(102, 68, 204, 255), () => OnSolo?.Invoke(), WEDGE_WIDTH, Key.P)); - buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", FontAwesome.Solid.Users, new Color4(94, 63, 186, 255), onMultiplayer, 0, Key.M)); - buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Playlists, @"button-default-select", OsuIcon.Charts, new Color4(94, 63, 186, 255), onPlaylists, 0, Key.L)); + buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), () => OnSolo?.Invoke(), WEDGE_WIDTH, Key.P)); + buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", OsuIcon.Online, new Color4(94, 63, 186, 255), onMultiplayer, 0, Key.M)); + buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Playlists, @"button-default-select", OsuIcon.Tournament, new Color4(94, 63, 186, 255), onPlaylists, 0, Key.L)); buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play); - buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", HexaconsIcons.Beatmap, new Color4(238, 170, 0, 255), () => OnEditBeatmap?.Invoke(), WEDGE_WIDTH, Key.B)); - buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", HexaconsIcons.Editor, new Color4(220, 160, 0, 255), () => OnEditSkin?.Invoke(), 0, Key.S)); + buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), () => OnEditBeatmap?.Invoke(), WEDGE_WIDTH, Key.B)); + buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", OsuIcon.SkinB, new Color4(220, 160, 0, 255), () => OnEditSkin?.Invoke(), 0, Key.S)); buttonsEdit.ForEach(b => b.VisibleState = ButtonSystemState.Edit); buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), () => State = ButtonSystemState.Play, WEDGE_WIDTH, Key.P)); buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-play-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), () => State = ButtonSystemState.Edit, 0, Key.E)); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.ChevronDownCircle, new Color4(165, 204, 0, 255), () => OnBeatmapListing?.Invoke(), 0, Key.B, Key.D)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), () => OnBeatmapListing?.Invoke(), 0, Key.B, Key.D)); if (host.CanExit) buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), () => OnExit?.Invoke(), 0, Key.Q)); diff --git a/osu.Game/Screens/Menu/Disclaimer.cs b/osu.Game/Screens/Menu/Disclaimer.cs deleted file mode 100644 index 539d58d2d7..0000000000 --- a/osu.Game/Screens/Menu/Disclaimer.cs +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Screens; -using osu.Framework.Utils; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Menu -{ - public partial class Disclaimer : StartupScreen - { - private SpriteIcon icon; - private Color4 iconColour; - private LinkFlowContainer textFlow; - private LinkFlowContainer supportFlow; - - private Drawable heart; - - private const float icon_y = -85; - private const float icon_size = 30; - - private readonly OsuScreen nextScreen; - - private readonly Bindable currentUser = new Bindable(); - private FillFlowContainer fill; - - private readonly List expendableText = new List(); - - public Disclaimer(OsuScreen nextScreen = null) - { - this.nextScreen = nextScreen; - ValidForResume = false; - } - - [Resolved] - private IAPIProvider api { get; set; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - InternalChildren = new Drawable[] - { - icon = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = OsuIcon.Logo, - Size = new Vector2(icon_size), - Y = icon_y, - }, - fill = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Y = icon_y, - Anchor = Anchor.Centre, - Origin = Anchor.TopCentre, - Children = new Drawable[] - { - textFlow = new LinkFlowContainer - { - Width = 680, - AutoSizeAxes = Axes.Y, - TextAnchor = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Spacing = new Vector2(0, 2), - }, - } - }, - supportFlow = new LinkFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - TextAnchor = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Padding = new MarginPadding(20), - Alpha = 0, - Spacing = new Vector2(0, 2), - }, - }; - - textFlow.AddText("this is osu!", t => t.Font = t.Font.With(Typeface.Torus, 30, FontWeight.Regular)); - - expendableText.Add(textFlow.AddText("lazer", t => - { - t.Font = t.Font.With(Typeface.Torus, 30, FontWeight.Regular); - t.Colour = colours.PinkLight; - })); - - static void formatRegular(SpriteText t) => t.Font = OsuFont.GetFont(size: 20, weight: FontWeight.Regular); - static void formatSemiBold(SpriteText t) => t.Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold); - - textFlow.NewParagraph(); - - textFlow.AddText("the next ", formatRegular); - textFlow.AddText("major update", t => - { - t.Font = t.Font.With(Typeface.Torus, 20, FontWeight.SemiBold); - t.Colour = colours.Pink; - }); - expendableText.Add(textFlow.AddText(" coming to osu!", formatRegular)); - textFlow.AddText(".", formatRegular); - - textFlow.NewParagraph(); - textFlow.NewParagraph(); - - textFlow.AddParagraph("today's tip:", formatSemiBold); - textFlow.AddParagraph(getRandomTip(), formatRegular); - textFlow.NewParagraph(); - - textFlow.NewParagraph(); - - iconColour = colours.Yellow; - - // manually transfer the user once, but only do the final bind in LoadComplete to avoid thread woes (API scheduler could run while this screen is still loading). - // the manual transfer is here to ensure all text content is loaded ahead of time as this is very early in the game load process and we want to avoid stutters. - currentUser.Value = api.LocalUser.Value; - currentUser.BindValueChanged(e => - { - supportFlow.Children.ForEach(d => d.FadeOut().Expire()); - - if (e.NewValue.IsSupporter) - { - supportFlow.AddText("Eternal thanks to you for supporting osu!", formatSemiBold); - } - else - { - supportFlow.AddText("Consider becoming an ", formatSemiBold); - supportFlow.AddLink("osu!supporter", "https://osu.ppy.sh/home/support", formatSemiBold); - supportFlow.AddText(" to help support osu!'s development", formatSemiBold); - } - - supportFlow.AddIcon(FontAwesome.Solid.Heart, t => - { - heart = t; - - t.Padding = new MarginPadding { Left = 5, Top = 3 }; - t.Font = t.Font.With(size: 20); - t.Origin = Anchor.Centre; - t.Colour = colours.Pink; - - Schedule(() => heart?.FlashColour(Color4.White, 750, Easing.OutQuint).Loop()); - }); - - if (supportFlow.IsPresent) - supportFlow.FadeInFromZero(500); - }, true); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - if (nextScreen != null) - LoadComponentAsync(nextScreen); - - ((IBindable)currentUser).BindTo(api.LocalUser); - } - - public override void OnSuspending(ScreenTransitionEvent e) - { - base.OnSuspending(e); - - // Once this screen has finished being displayed, we don't want to unnecessarily handle user change events. - currentUser.UnbindAll(); - } - - public override void OnEntering(ScreenTransitionEvent e) - { - base.OnEntering(e); - - icon.RotateTo(10); - icon.FadeOut(); - icon.ScaleTo(0.5f); - - icon.Delay(500).FadeIn(500).ScaleTo(1, 500, Easing.OutQuint); - - using (BeginDelayedSequence(3000)) - { - icon.FadeColour(iconColour, 200, Easing.OutQuint); - icon.MoveToY(icon_y * 1.3f, 500, Easing.OutCirc) - .RotateTo(-360, 520, Easing.OutQuint) - .Then() - .MoveToY(icon_y, 160, Easing.InQuart) - .FadeColour(Color4.White, 160); - - using (BeginDelayedSequence(520 + 160)) - { - fill.MoveToOffset(new Vector2(0, 15), 160, Easing.OutQuart); - Schedule(() => expendableText.SelectMany(t => t.Drawables).ForEach(t => - { - t.FadeOut(100); - t.ScaleTo(new Vector2(0, 1), 100, Easing.OutQuart); - })); - } - } - - supportFlow.FadeOut().Delay(2000).FadeIn(500); - double delay = 500; - foreach (var c in textFlow.Children) - c.FadeTo(0.001f).Delay(delay += 20).FadeIn(500); - - this - .FadeInFromZero(500) - .Then(5500) - .FadeOut(250) - .ScaleTo(0.9f, 250, Easing.InQuint) - .Finally(_ => - { - if (nextScreen != null) - this.Push(nextScreen); - }); - } - - private string getRandomTip() - { - string[] tips = - { - "You can press Ctrl-T anywhere in the game to toggle the toolbar!", - "You can press Ctrl-O anywhere in the game to access options!", - "All settings are dynamic and take effect in real-time. Try pausing and changing the skin while playing!", - "New features are coming online every update. Make sure to stay up-to-date!", - "If you find the UI too large or small, try adjusting UI scale in settings!", - "Try adjusting the \"Screen Scaling\" mode to change your gameplay or UI area, even in fullscreen!", - "What used to be \"osu!direct\" is available to all users just like on the website. You can access it anywhere using Ctrl-B!", - "Seeking in replays is available by dragging on the difficulty bar at the bottom of the screen!", - "Multithreading support means that even with low \"FPS\" your input and judgements will be accurate!", - "Try scrolling down in the mod select panel to find a bunch of new fun mods!", - "Most of the web content (profiles, rankings, etc.) are available natively in-game from the icons on the toolbar!", - "Get more details, hide or delete a beatmap by right-clicking on its panel at song select!", - "All delete operations are temporary until exiting. Restore accidentally deleted content from the maintenance settings!", - "Check out the \"playlists\" system, which lets users create their own custom and permanent leaderboards!", - "Toggle advanced frame / thread statistics with Ctrl-F11!", - "Take a look under the hood at performance counters and enable verbose performance logging with Ctrl-F2!", - }; - - return tips[RNG.Next(0, tips.Length)]; - } - } -} diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index de7732dd5e..ac7dffc241 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -95,6 +95,8 @@ namespace osu.Game.Screens.Menu Colour = Color4.Black }; + public override bool? AllowGlobalTrackControl => false; + protected IntroScreen([CanBeNull] Func createNextScreen = null) { this.createNextScreen = createNextScreen; diff --git a/osu.Game/Screens/Menu/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index fa26cfab46..b722b83280 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -189,7 +189,7 @@ namespace osu.Game.Screens.Menu Source.frequencyAmplitudes.AsSpan().CopyTo(audioData); } - public override void Draw(IRenderer renderer) + protected override void Draw(IRenderer renderer) { base.Draw(renderer); diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index c25e62d69e..a75edd1cff 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; +using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -25,6 +26,7 @@ using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Online.API; using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; @@ -49,6 +51,8 @@ namespace osu.Game.Screens.Menu public override bool AllowExternalScreenChange => true; + public override bool? AllowGlobalTrackControl => true; + private Screen songSelect; private MenuSideFlashes sideFlashes; @@ -76,6 +80,9 @@ namespace osu.Game.Screens.Menu [Resolved(canBeNull: true)] private IDialogOverlay dialogOverlay { get; set; } + [Resolved(canBeNull: true)] + private VersionManager versionManager { get; set; } + protected override BackgroundScreen CreateBackground() => new BackgroundScreenDefault(); protected override bool PlayExitSound => false; @@ -91,6 +98,10 @@ namespace osu.Game.Screens.Menu private ParallaxContainer buttonsContainer; private SongTicker songTicker; private Container logoTarget; + private SystemTitle systemTitle; + private MenuTip menuTip; + private FillFlowContainer bottomElementsFlow; + private SupporterDisplay supporterDisplay; private Sample reappearSampleSwoosh; @@ -153,6 +164,33 @@ namespace osu.Game.Screens.Menu Margin = new MarginPadding { Right = 15, Top = 5 } }, new KiaiMenuFountains(), + bottomElementsFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Spacing = new Vector2(5), + Children = new Drawable[] + { + menuTip = new MenuTip + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + systemTitle = new SystemTitle + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + } + }, + supporterDisplay = new SupporterDisplay + { + Margin = new MarginPadding(5), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, holdToExitGameOverlay?.CreateProxy() ?? Empty() }); @@ -163,10 +201,12 @@ namespace osu.Game.Screens.Menu case ButtonSystemState.Initial: case ButtonSystemState.Exit: ApplyToBackground(b => b.FadeColour(Color4.White, 500, Easing.OutSine)); + systemTitle.State.Value = Visibility.Hidden; break; default: ApplyToBackground(b => b.FadeColour(OsuColour.Gray(0.8f), 500, Easing.OutSine)); + systemTitle.State.Value = Visibility.Visible; break; } }; @@ -263,6 +303,16 @@ namespace osu.Game.Screens.Menu } } + protected override void Update() + { + base.Update(); + + bottomElementsFlow.Margin = new MarginPadding + { + Bottom = (versionManager?.DrawHeight + 5) ?? 0 + }; + } + protected override void LogoSuspending(OsuLogo logo) { var seq = logo.FadeOut(300, Easing.InSine) @@ -299,6 +349,13 @@ namespace osu.Game.Screens.Menu buttonsContainer.MoveTo(new Vector2(-800, 0), FADE_OUT_DURATION, Easing.InSine); sideFlashes.FadeOut(64, Easing.OutQuint); + + bottomElementsFlow + .ScaleTo(0.9f, 1000, Easing.OutQuint) + .FadeOut(500, Easing.OutQuint); + + supporterDisplay + .FadeOut(500, Easing.OutQuint); } public override void OnResuming(ScreenTransitionEvent e) @@ -315,6 +372,13 @@ namespace osu.Game.Screens.Menu preloadSongSelect(); musicController.EnsurePlayingSomething(); + + // Cycle tip on resuming + menuTip.ShowNextTip(); + + bottomElementsFlow + .ScaleTo(1, 1000, Easing.OutQuint) + .FadeIn(1000, Easing.OutQuint); } public override bool OnExiting(ScreenExitEvent e) @@ -330,7 +394,12 @@ namespace osu.Game.Screens.Menu if (requiresConfirmation) { if (dialogOverlay.CurrentDialog is ConfirmExitDialog exitDialog) - exitDialog.PerformOkAction(); + { + if (exitDialog.Buttons.OfType().FirstOrDefault() != null) + exitDialog.PerformOkAction(); + else + exitDialog.Flash(); + } else { dialogOverlay.Push(new ConfirmExitDialog(() => @@ -352,6 +421,13 @@ namespace osu.Game.Screens.Menu songTicker.Hide(); this.FadeOut(3000); + + bottomElementsFlow + .FadeOut(500, Easing.OutQuint); + + supporterDisplay + .FadeOut(500, Easing.OutQuint); + return base.OnExiting(e); } diff --git a/osu.Game/Screens/Menu/MainMenuButton.cs b/osu.Game/Screens/Menu/MainMenuButton.cs index bc638b44ac..422599a4a8 100644 --- a/osu.Game/Screens/Menu/MainMenuButton.cs +++ b/osu.Game/Screens/Menu/MainMenuButton.cs @@ -31,6 +31,9 @@ namespace osu.Game.Screens.Menu /// public partial class MainMenuButton : BeatSyncedContainer, IStateful { + public const float BOUNCE_COMPRESSION = 0.9f; + public const float HOVER_SCALE = 1.2f; + public const float BOUNCE_ROTATION = 8; public event Action? StateChanged; public readonly Key[] TriggerKeys; @@ -125,8 +128,9 @@ namespace osu.Game.Screens.Menu Shadow = true, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(30), + Size = new Vector2(32), Position = new Vector2(0, 0), + Margin = new MarginPadding { Top = -4 }, Icon = symbol }, new OsuSpriteText @@ -136,6 +140,7 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.Centre, Origin = Anchor.Centre, Position = new Vector2(0, 35), + Margin = new MarginPadding { Left = -3 }, Text = text } } @@ -153,14 +158,14 @@ namespace osu.Game.Screens.Menu double duration = timingPoint.BeatLength / 2; - icon.RotateTo(rightward ? 10 : -10, duration * 2, Easing.InOutSine); + icon.RotateTo(rightward ? BOUNCE_ROTATION : -BOUNCE_ROTATION, duration * 2, Easing.InOutSine); icon.Animate( i => i.MoveToY(-10, duration, Easing.Out), - i => i.ScaleTo(1, duration, Easing.Out) + i => i.ScaleTo(HOVER_SCALE, duration, Easing.Out) ).Then( i => i.MoveToY(0, duration, Easing.In), - i => i.ScaleTo(new Vector2(1, 0.9f), duration, Easing.In) + i => i.ScaleTo(new Vector2(HOVER_SCALE, HOVER_SCALE * BOUNCE_COMPRESSION), duration, Easing.In) ); rightward = !rightward; @@ -177,8 +182,8 @@ namespace osu.Game.Screens.Menu double duration = TimeUntilNextBeat; icon.ClearTransforms(); - icon.RotateTo(rightward ? -10 : 10, duration, Easing.InOutSine); - icon.ScaleTo(new Vector2(1, 0.9f), duration, Easing.Out); + icon.RotateTo(rightward ? -BOUNCE_ROTATION : BOUNCE_ROTATION, duration, Easing.InOutSine); + icon.ScaleTo(new Vector2(HOVER_SCALE, HOVER_SCALE * BOUNCE_COMPRESSION), duration, Easing.Out); return true; } diff --git a/osu.Game/Screens/Menu/MenuTip.cs b/osu.Game/Screens/Menu/MenuTip.cs new file mode 100644 index 0000000000..da349373c3 --- /dev/null +++ b/osu.Game/Screens/Menu/MenuTip.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Menu +{ + public partial class MenuTip : CompositeDrawable + { + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private LinkFlowContainer textFlow = null!; + + private Bindable showMenuTips = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerExponent = 2.5f, + CornerRadius = 15, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Alpha = 0.4f, + }, + } + }, + textFlow = new LinkFlowContainer + { + Width = 600, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.TopCentre, + Spacing = new Vector2(0, 2), + Margin = new MarginPadding(10) + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + showMenuTips = config.GetBindable(OsuSetting.MenuTips); + showMenuTips.BindValueChanged(_ => ShowNextTip(), true); + } + + public void ShowNextTip() + { + if (!showMenuTips.Value) + { + this.FadeOut(100, Easing.OutQuint); + return; + } + + static void formatRegular(SpriteText t) => t.Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular); + static void formatSemiBold(SpriteText t) => t.Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold); + + string tip = getRandomTip(); + + textFlow.Clear(); + textFlow.AddParagraph("a tip for you:", formatSemiBold); + textFlow.AddParagraph(tip, formatRegular); + + this.FadeInFromZero(200, Easing.OutQuint) + .Delay(1000 + 80 * tip.Length) + .Then() + .FadeOutFromOne(2000, Easing.OutQuint); + } + + private string getRandomTip() + { + string[] tips = + { + "Press Ctrl-T anywhere in the game to toggle the toolbar!", + "Press Ctrl-O anywhere in the game to access options!", + "All settings are dynamic and take effect in real-time. Try changing the skin while watching autoplay!", + "New features are coming online every update. Make sure to stay up-to-date!", + "If you find the UI too large or small, try adjusting UI scale in settings!", + "Try adjusting the \"Screen Scaling\" mode to change your gameplay or UI area, even in fullscreen!", + "What used to be \"osu!direct\" is available to all users just like on the website. You can access it anywhere using Ctrl-B!", + "Seeking in replays is available by dragging on the progress bar at the bottom of the screen or by using the left and right arrow keys!", + "Multithreading support means that even with low \"FPS\" your input and judgements will be accurate!", + "Try scrolling right in mod select to find a bunch of new fun mods!", + "Most of the web content (profiles, rankings, etc.) are available natively in-game from the icons on the toolbar!", + "Get more details, hide or delete a beatmap by right-clicking on its panel at song select!", + "All delete operations are temporary until exiting. Restore accidentally deleted content from the maintenance settings!", + "Check out the \"playlists\" system, which lets users create their own custom and permanent leaderboards!", + "Toggle advanced frame / thread statistics with Ctrl-F11!", + "Take a look under the hood at performance counters and enable verbose performance logging with Ctrl-F2!", + "You can pause during a replay by pressing Space!", + "Most of the hotkeys in the game are configurable and can be changed to anything you want. Check the bindings panel under input settings!", + "When your gameplay HUD is hidden, you can press and hold Ctrl to view it temporarily!", + "Your gameplay HUD can be customized by using the skin layout editor. Open it at any time via Ctrl-Shift-S!", + "Drag and drop any image into the skin editor to load it in quickly!", + "You can create mod presets to make toggling your favorite mod combinations easier!", + "Many mods have customisation settings that drastically change how they function. Click the Mod Customisation button in mod select to view settings!", + "Press Ctrl-Shift-R to switch to a random skin!", + "Press Ctrl-Shift-F to toggle the FPS Counter. But make sure not to pay too much attention to it!", + "While watching a replay, press Ctrl-H to toggle replay settings!", + "You can easily copy the mods from scores on a leaderboard by right-clicking on them!", + "Ctrl-Enter at song select will start a beatmap in autoplay mode!" + }; + + return tips[RNG.Next(0, tips.Length)]; + } + } +} diff --git a/osu.Game/Screens/Menu/SupporterDisplay.cs b/osu.Game/Screens/Menu/SupporterDisplay.cs new file mode 100644 index 0000000000..6639300f4a --- /dev/null +++ b/osu.Game/Screens/Menu/SupporterDisplay.cs @@ -0,0 +1,167 @@ +// 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.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Menu +{ + public partial class SupporterDisplay : CompositeDrawable + { + private LinkFlowContainer supportFlow = null!; + + private Drawable heart = null!; + + private readonly IBindable currentUser = new Bindable(); + + private Box backgroundBox = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Height = 40; + + AutoSizeAxes = Axes.X; + AutoSizeDuration = 1000; + AutoSizeEasing = Easing.OutQuint; + + Masking = true; + CornerExponent = 2.5f; + CornerRadius = 15; + + InternalChildren = new Drawable[] + { + backgroundBox = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.4f, + }, + supportFlow = new LinkFlowContainer + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Spacing = new Vector2(0, 2), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + const float font_size = 14; + + static void formatSemiBold(SpriteText t) => t.Font = OsuFont.GetFont(size: font_size, weight: FontWeight.SemiBold); + + currentUser.BindTo(api.LocalUser); + currentUser.BindValueChanged(e => + { + supportFlow.Children.ForEach(d => d.FadeOut().Expire()); + + if (e.NewValue.IsSupporter) + { + supportFlow.AddText("Eternal thanks to you for supporting osu!", formatSemiBold); + + backgroundBox.FadeColour(colours.Pink, 250); + } + else + { + supportFlow.AddText("Consider becoming an ", formatSemiBold); + supportFlow.AddLink("osu!supporter", "https://osu.ppy.sh/home/support", formatSemiBold); + supportFlow.AddText(" to help support osu!'s development", formatSemiBold); + + backgroundBox.FadeColour(colours.Pink4, 250); + } + + supportFlow.AddIcon(FontAwesome.Solid.Heart, t => + { + heart = t; + + t.Padding = new MarginPadding { Left = 5, Top = 1 }; + t.Font = t.Font.With(size: font_size); + t.Origin = Anchor.Centre; + t.Colour = colours.Pink; + + Schedule(() => + { + heart?.FlashColour(Color4.White, 750, Easing.OutQuint).Loop(); + }); + }); + }, true); + + this + .FadeOut() + .Delay(1000) + .FadeInFromZero(800, Easing.OutQuint); + + scheduleDismissal(); + } + + protected override bool OnClick(ClickEvent e) + { + dismissalDelegate?.Cancel(); + + supportFlow.BypassAutoSizeAxes = Axes.X; + this.FadeOut(500, Easing.OutQuint); + return base.OnClick(e); + } + + protected override bool OnHover(HoverEvent e) + { + backgroundBox.FadeTo(0.6f, 500, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + backgroundBox.FadeTo(0.4f, 500, Easing.OutQuint); + base.OnHoverLost(e); + } + + private ScheduledDelegate? dismissalDelegate; + + private void scheduleDismissal() + { + dismissalDelegate?.Cancel(); + dismissalDelegate = Scheduler.AddDelayed(() => + { + // If the user is hovering they may want to interact with the link. + // Give them more time. + if (IsHovered) + { + scheduleDismissal(); + return; + } + + dismissalDelegate?.Cancel(); + + AutoSizeEasing = Easing.In; + supportFlow.BypassAutoSizeAxes = Axes.X; + this + .Delay(200) + .FadeOut(750, Easing.Out); + }, 6000); + } + } +} diff --git a/osu.Game/Screens/Menu/SystemTitle.cs b/osu.Game/Screens/Menu/SystemTitle.cs new file mode 100644 index 0000000000..813a470ed6 --- /dev/null +++ b/osu.Game/Screens/Menu/SystemTitle.cs @@ -0,0 +1,186 @@ +// 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.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.Menu +{ + public partial class SystemTitle : VisibilityContainer + { + internal Bindable Current { get; } = new Bindable(); + + private const float transition_duration = 500; + + private Container content = null!; + private CancellationTokenSource? cancellationTokenSource; + private SystemTitleImage? currentImage; + + private ScheduledDelegate? openUrlAction; + + [BackgroundDependencyLoader] + private void load(OsuGame? game) + { + AutoSizeAxes = Axes.Both; + AutoSizeDuration = transition_duration; + AutoSizeEasing = Easing.OutQuint; + + InternalChild = content = new OsuClickableContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Action = () => + { + currentImage?.Flash(); + + // Delay slightly to allow animation to play out. + openUrlAction?.Cancel(); + openUrlAction = Scheduler.AddDelayed(() => + { + if (!string.IsNullOrEmpty(Current.Value?.Url)) + game?.HandleLink(Current.Value.Url); + }, 250); + } + }; + } + + protected override void PopIn() => content.FadeInFromZero(transition_duration, Easing.OutQuint); + + protected override void PopOut() => content.FadeOut(transition_duration, Easing.OutQuint); + + protected override bool OnHover(HoverEvent e) + { + content.ScaleTo(1.05f, 2000, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + content.ScaleTo(1f, 500, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + content.ScaleTo(0.95f, 500, Easing.OutQuint); + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + content + .ScaleTo(0.95f) + .ScaleTo(1, 500, Easing.OutElastic); + base.OnMouseUp(e); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => loadNewImage(), true); + + checkForUpdates(); + } + + private void checkForUpdates() + { + var request = new GetSystemTitleRequest(); + Task.Run(() => request.Perform()) + .ContinueWith(r => + { + if (r.IsCompletedSuccessfully) + Schedule(() => Current.Value = request.ResponseObject); + + // if the request failed, "observe" the exception. + // it isn't very important why this failed, as it's only for display. + // the inner error will be logged by framework mechanisms anyway. + if (r.IsFaulted) + _ = r.Exception; + + Scheduler.AddDelayed(checkForUpdates, TimeSpan.FromMinutes(5).TotalMilliseconds); + }); + } + + private void loadNewImage() + { + cancellationTokenSource?.Cancel(); + cancellationTokenSource = null; + currentImage?.FadeOut(500, Easing.OutQuint).Expire(); + + if (string.IsNullOrEmpty(Current.Value?.Image)) + return; + + LoadComponentAsync(new SystemTitleImage(Current.Value), loaded => + { + if (!loaded.SystemTitle.Equals(Current.Value)) + loaded.Dispose(); + + content.Add(currentImage = loaded); + }, (cancellationTokenSource ??= new CancellationTokenSource()).Token); + } + + [LongRunningLoad] + private partial class SystemTitleImage : CompositeDrawable + { + public readonly APISystemTitle SystemTitle; + + private Sprite flash = null!; + + public SystemTitleImage(APISystemTitle systemTitle) + { + SystemTitle = systemTitle; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textureStore) + { + Texture? texture = textureStore.Get(SystemTitle.Image); + if (texture != null && SystemTitle.Image.Contains(@"@2x")) + texture.ScaleAdjust *= 2; + + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Sprite { Texture = texture }, + flash = new Sprite + { + Texture = texture, + Blending = BlendingParameters.Additive, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.FadeInFromZero(500, Easing.OutQuint); + flash.FadeOutFromOne(4000, Easing.OutQuint); + } + + public Drawable Flash() + { + flash.FadeInFromZero(50) + .Then() + .FadeOut(500, Easing.OutQuint); + + return this; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 7c12e6eab5..56256bb15c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -349,6 +349,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer addItemButton.Alpha = localUserCanAddItem ? 1 : 0; Scheduler.AddOnce(UpdateMods); + + Activity.Value = new UserActivity.InLobby(Room); } private bool localUserCanAddItem => client.IsHost || Room.QueueMode.Value != QueueMode.HostOnly; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index e6d9dd4cd0..d9043df1d5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -26,9 +26,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { protected override bool PauseOnFocusLost => false; - // Disallow fails in multiplayer for now. - protected override bool CheckModsAllowFailure() => false; - protected override UserActivity InitialActivity => new UserActivity.InMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); [Resolved] @@ -55,6 +52,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { AllowPause = false, AllowRestart = false, + AllowFailAnimation = false, AllowSkipping = room.AutoSkip.Value, AutomaticallySkipIntro = room.AutoSkip.Value, AlwaysShowLeaderboard = true, diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index 3ca82ec00b..e18612c955 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -4,12 +4,14 @@ #nullable disable using System.Collections.Generic; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osu.Game.Screens.Play.Break; namespace osu.Game.Screens.Play @@ -113,7 +115,7 @@ namespace osu.Game.Screens.Play if (scoreProcessor != null) { info.AccuracyDisplay.Current.BindTo(scoreProcessor.Accuracy); - info.GradeDisplay.Current.BindTo(scoreProcessor.Rank); + ((IBindable)info.GradeDisplay.Current).BindTo(scoreProcessor.Rank); } } diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 5a713fdae7..4def1d36bb 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -17,6 +17,7 @@ namespace osu.Game.Screens.Play /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use. /// [Cached(typeof(IGameplayClock))] + [Cached(typeof(GameplayClockContainer))] public partial class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock { public IBindable IsPaused => isPaused; diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 0680842891..440b8d37b9 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -44,7 +44,15 @@ namespace osu.Game.Screens.Play /// /// Action that is invoked when is triggered. /// - protected virtual Action BackAction => () => InternalButtons.LastOrDefault()?.TriggerClick(); + protected virtual Action BackAction => () => + { + // We prefer triggering the button click as it will animate... + // but sometimes buttons aren't present (see FailOverlay's constructor as an example). + if (Buttons.Any()) + Buttons.Last().TriggerClick(); + else + OnQuit?.Invoke(); + }; /// /// Action that is invoked when is triggered. diff --git a/osu.Game/Screens/Play/GameplayOffsetControl.cs b/osu.Game/Screens/Play/GameplayOffsetControl.cs new file mode 100644 index 0000000000..2f0cb821ec --- /dev/null +++ b/osu.Game/Screens/Play/GameplayOffsetControl.cs @@ -0,0 +1,107 @@ +// 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.Shapes; +using osu.Framework.Threading; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Screens.Play.PlayerSettings; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play +{ + /// + /// This provides the ability to change the offset while in gameplay. + /// Eventually this should be replaced with all settings from PlayerLoader being accessible from the game. + /// + internal partial class GameplayOffsetControl : VisibilityContainer + { + protected override bool StartHidden => true; + + public override bool PropagateNonPositionalInputSubTree => true; + + // Disable interaction for now to avoid any funny business with slider bar dragging. + public override bool PropagatePositionalInputSubTree => false; + + private BeatmapOffsetControl offsetControl = null!; + + private OsuTextFlowContainer text = null!; + + private ScheduledDelegate? hideOp; + + public GameplayOffsetControl() + { + AutoSizeAxes = Axes.Y; + Width = SettingsToolboxGroup.CONTAINER_WIDTH; + + Masking = true; + CornerRadius = 5; + + // Allow BeatmapOffsetControl to handle keyboard input. + AlwaysPresent = true; + + Anchor = Anchor.CentreRight; + Origin = Anchor.CentreRight; + + X = 100; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? colourProvider) + { + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.8f, + Colour = colourProvider?.Background4 ?? Color4.Black, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Spacing = new Vector2(5), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + offsetControl = new BeatmapOffsetControl(), + text = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(weight: FontWeight.SemiBold)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.TopCentre, + } + } + }, + }; + + offsetControl.Current.BindValueChanged(val => + { + text.Text = BeatmapOffsetControl.GetOffsetExplanatoryText(val.NewValue); + Show(); + + hideOp?.Cancel(); + hideOp = Scheduler.AddDelayed(Hide, 500); + }); + } + + protected override void PopIn() + { + this.FadeIn(500, Easing.OutQuint) + .MoveToX(0, 500, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(500, Easing.InQuint) + .MoveToX(100, 500, Easing.InQuint); + } + } +} diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index c2162d4df2..cc399a0fbe 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.cs @@ -46,7 +46,7 @@ namespace osu.Game.Screens.Play public bool HasPassed { get; set; } /// - /// Whether the user failed during gameplay. + /// Whether the user failed during gameplay. This is only set when the gameplay session has completed due to the fail. /// public bool HasFailed { get; set; } diff --git a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs index 521ad63426..171aa3f44b 100644 --- a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs @@ -83,12 +83,14 @@ namespace osu.Game.Screens.Play.HUD }, fractionPart = new ArgonCounterTextComponent(Anchor.TopLeft) { + RequiredDisplayDigits = { Value = 2 }, WireframeOpacity = { BindTarget = WireframeOpacity }, Scale = new Vector2(0.5f), }, percentText = new ArgonCounterTextComponent(Anchor.TopLeft) { Text = @"%", + RequiredDisplayDigits = { Value = 1 }, WireframeOpacity = { BindTarget = WireframeOpacity } }, } diff --git a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs index 5ea7fd0b82..1d6ca3c893 100644 --- a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs @@ -57,6 +57,31 @@ namespace osu.Game.Screens.Play.HUD }); } + public override int DisplayedCount + { + get => base.DisplayedCount; + set + { + base.DisplayedCount = value; + updateWireframe(); + } + } + + private void updateWireframe() + { + text.RequiredDisplayDigits.Value = getDigitsRequiredForDisplayCount(); + } + + private int getDigitsRequiredForDisplayCount() + { + // one for the single presumed starting digit, one for the "x" at the end. + int digitsRequired = 2; + long c = DisplayedCount; + while ((c /= 10) > 0) + digitsRequired++; + return digitsRequired; + } + protected override LocalisableString FormatCount(int count) => $@"{count}x"; protected override IHasText CreateText() => text = new ArgonCounterTextComponent(Anchor.TopLeft, MatchesStrings.MatchScoreStatsCombo.ToUpper()) diff --git a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs index 2a3f4365cb..a11f2f01cd 100644 --- a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs +++ b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; +using System.Collections.Generic; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -33,14 +33,7 @@ namespace osu.Game.Screens.Play.HUD public LocalisableString Text { get => textPart.Text; - set - { - int remainingCount = RequiredDisplayDigits.Value - value.ToString().Count(char.IsDigit); - string remainingText = remainingCount > 0 ? new string('#', remainingCount) : string.Empty; - - wireframesPart.Text = remainingText + value; - textPart.Text = value; - } + set => textPart.Text = value; } public ArgonCounterTextComponent(Anchor anchor, LocalisableString? label = null) @@ -81,6 +74,8 @@ namespace osu.Game.Screens.Play.HUD } } }; + + RequiredDisplayDigits.BindValueChanged(digits => wireframesPart.Text = new string('#', digits.NewValue)); } private string textLookup(char c) @@ -137,33 +132,49 @@ namespace osu.Game.Screens.Play.HUD [BackgroundDependencyLoader] private void load(TextureStore textures) { + const string font_name = @"argon-counter"; + Spacing = new Vector2(-2f, 0f); - Font = new FontUsage(@"argon-counter", 1); - glyphStore = new GlyphStore(textures, getLookup); + Font = new FontUsage(font_name, 1); + glyphStore = new GlyphStore(font_name, textures, getLookup); } protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); private class GlyphStore : ITexturedGlyphLookupStore { + private readonly string fontName; private readonly TextureStore textures; private readonly Func getLookup; - public GlyphStore(TextureStore textures, Func getLookup) + private readonly Dictionary cache = new Dictionary(); + + public GlyphStore(string fontName, TextureStore textures, Func getLookup) { + this.fontName = fontName; this.textures = textures; this.getLookup = getLookup; } public ITexturedCharacterGlyph? Get(string? fontName, char character) { + // We only service one font. + if (fontName != this.fontName) + return null; + + if (cache.TryGetValue(character, out var cached)) + return cached; + string lookup = getLookup(character); var texture = textures.Get($"Gameplay/Fonts/{fontName}-{lookup}"); - if (texture == null) - return null; + TexturedCharacterGlyph? glyph = null; - return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 0.125f); + if (texture != null) + glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 0.125f); + + cache[character] = glyph; + return glyph; } public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index eaaf1c3c14..236bd3366d 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -3,9 +3,9 @@ using System; using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -58,39 +58,12 @@ namespace osu.Game.Screens.Play.HUD private bool displayingMiss => resetMissBarDelegate != null; - private readonly List missBarVertices = new List(); - private readonly List healthBarVertices = new List(); + private readonly List vertices = new List(); private double glowBarValue; - public double GlowBarValue - { - get => glowBarValue; - set - { - if (glowBarValue == value) - return; - - glowBarValue = value; - Scheduler.AddOnce(updatePathVertices); - } - } - private double healthBarValue; - public double HealthBarValue - { - get => healthBarValue; - set - { - if (healthBarValue == value) - return; - - healthBarValue = value; - Scheduler.AddOnce(updatePathVertices); - } - } - public const float MAIN_PATH_RADIUS = 10f; private const float curve_start_offset = 70; @@ -100,6 +73,8 @@ namespace osu.Game.Screens.Play.HUD private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + private readonly Cached pathVerticesCache = new Cached(); + public ArgonHealthDisplay() { AddLayout(drawSizeLayout); @@ -158,7 +133,6 @@ namespace osu.Game.Screens.Play.HUD base.LoadComplete(); HealthProcessor.NewJudgement += onNewJudgement; - Current.BindValueChanged(onCurrentChanged, true); // we're about to set `RelativeSizeAxes` depending on the value of `UseRelativeSize`. // setting `RelativeSizeAxes` internally transforms absolute sizing to relative and back to keep the size the same, @@ -173,31 +147,6 @@ namespace osu.Game.Screens.Play.HUD private void onNewJudgement(JudgementResult result) => pendingMissAnimation |= !result.IsHit; - private void onCurrentChanged(ValueChangedEvent valueChangedEvent) - // schedule display updates one frame later to ensure we know the judgement result causing this change (if there is one). - => Scheduler.AddOnce(updateDisplay); - - private void updateDisplay() - { - double newHealth = Current.Value; - - if (newHealth >= GlowBarValue) - finishMissDisplay(); - - double time = newHealth > GlowBarValue ? 500 : 250; - - // TODO: this should probably use interpolation in update. - this.TransformTo(nameof(HealthBarValue), newHealth, time, Easing.OutQuint); - - if (pendingMissAnimation && newHealth < GlowBarValue) - triggerMissDisplay(); - - pendingMissAnimation = false; - - if (!displayingMiss) - this.TransformTo(nameof(GlowBarValue), newHealth, time, Easing.OutQuint); - } - protected override void Update() { base.Update(); @@ -208,23 +157,44 @@ namespace osu.Game.Screens.Play.HUD drawSizeLayout.Validate(); } + healthBarValue = Interpolation.DampContinuously(healthBarValue, Current.Value, 50, Time.Elapsed); + if (!displayingMiss) + glowBarValue = Interpolation.DampContinuously(glowBarValue, Current.Value, 50, Time.Elapsed); + mainBar.Alpha = (float)Interpolation.DampContinuously(mainBar.Alpha, Current.Value > 0 ? 1 : 0, 40, Time.Elapsed); - glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, GlowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); + glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, glowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); + + updatePathVertices(); + } + + protected override void HealthChanged(bool increase) + { + if (Current.Value >= glowBarValue) + finishMissDisplay(); + + if (pendingMissAnimation) + { + triggerMissDisplay(); + pendingMissAnimation = false; + } + + base.HealthChanged(increase); + } + + protected override void FinishInitialAnimation(double value) + { + base.FinishInitialAnimation(value); + this.TransformTo(nameof(healthBarValue), value, 500, Easing.OutQuint); + this.TransformTo(nameof(glowBarValue), value, 250, Easing.OutQuint); } protected override void Flash() { base.Flash(); - mainBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour.Opacity(0.8f)) - .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.OutQuint); - if (!displayingMiss) { - glowBar.TransformTo(nameof(BarPath.BarColour), Colour4.White, 30, Easing.OutQuint) - .Then() - .TransformTo(nameof(BarPath.BarColour), main_bar_colour, 1000, Easing.OutQuint); - + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.GlowColour), Colour4.White, 30, Easing.OutQuint) .Then() .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.OutQuint); @@ -238,13 +208,15 @@ namespace osu.Game.Screens.Play.HUD this.Delay(500).Schedule(() => { - this.TransformTo(nameof(GlowBarValue), Current.Value, 300, Easing.OutQuint); + this.TransformTo(nameof(glowBarValue), Current.Value, 300, Easing.OutQuint); finishMissDisplay(); }, out resetMissBarDelegate); + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.BarColour), new Colour4(255, 147, 147, 255), 100, Easing.OutQuint).Then() .TransformTo(nameof(BarPath.BarColour), new Colour4(255, 93, 93, 255), 800, Easing.OutQuint); + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255).Lighten(0.2f)) .TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255), 800, Easing.OutQuint); } @@ -256,6 +228,7 @@ namespace osu.Game.Screens.Play.HUD if (Current.Value > 0) { + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.BarColour), main_bar_colour, 300, Easing.In); glowBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.In); } @@ -295,7 +268,6 @@ namespace osu.Game.Screens.Play.HUD if (DrawWidth - padding < rescale_cutoff) rescalePathProportionally(); - List vertices = new List(); barPath.GetPathToProgress(vertices, 0.0, 1.0); background.Vertices = vertices; @@ -325,20 +297,25 @@ namespace osu.Game.Screens.Play.HUD private void updatePathVertices() { - barPath.GetPathToProgress(healthBarVertices, 0.0, healthBarValue); - barPath.GetPathToProgress(missBarVertices, healthBarValue, Math.Max(glowBarValue, healthBarValue)); + barPath.GetPathToProgress(vertices, 0.0, healthBarValue); + if (vertices.Count == 0) vertices.Add(Vector2.Zero); + Vector2 initialVertex = vertices[0]; + for (int i = 0; i < vertices.Count; i++) + vertices[i] -= initialVertex; - if (healthBarVertices.Count == 0) - healthBarVertices.Add(Vector2.Zero); + mainBar.Vertices = vertices; + mainBar.Position = initialVertex; - if (missBarVertices.Count == 0) - missBarVertices.Add(Vector2.Zero); + barPath.GetPathToProgress(vertices, healthBarValue, Math.Max(glowBarValue, healthBarValue)); + if (vertices.Count == 0) vertices.Add(Vector2.Zero); + initialVertex = vertices[0]; + for (int i = 0; i < vertices.Count; i++) + vertices[i] -= initialVertex; - glowBar.Vertices = missBarVertices.Select(v => v - missBarVertices[0]).ToList(); - glowBar.Position = missBarVertices[0]; + glowBar.Vertices = vertices; + glowBar.Position = initialVertex; - mainBar.Vertices = healthBarVertices.Select(v => v - healthBarVertices[0]).ToList(); - mainBar.Position = healthBarVertices[0]; + pathVerticesCache.Validate(); } protected override void Dispose(bool isDisposing) @@ -351,14 +328,17 @@ namespace osu.Game.Screens.Play.HUD private partial class BackgroundPath : SmoothPath { + private static readonly Color4 colour_white = Color4.White.Opacity(0.8f); + private static readonly Color4 colour_black = Color4.Black.Opacity(0.2f); + protected override Color4 ColourAt(float position) { if (position <= 0.16f) - return Color4.White.Opacity(0.8f); + return colour_white; return Interpolation.ValueAt(position, - Color4.White.Opacity(0.8f), - Color4.Black.Opacity(0.2f), + colour_white, + colour_black, -0.5f, 1f, Easing.OutQuint); } } @@ -397,12 +377,14 @@ namespace osu.Game.Screens.Play.HUD public float GlowPortion { get; init; } + private static readonly Colour4 transparent_black = Colour4.Black.Opacity(0.0f); + protected override Color4 ColourAt(float position) { if (position >= GlowPortion) return BarColour; - return Interpolation.ValueAt(position, Colour4.Black.Opacity(0.0f), GlowColour, 0.0, GlowPortion, Easing.InQuint); + return Interpolation.ValueAt(position, transparent_black, GlowColour, 0.0, GlowPortion, Easing.InQuint); } } } diff --git a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs index 005f7e36a7..f7ca218767 100644 --- a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -15,6 +16,8 @@ namespace osu.Game.Screens.Play.HUD { public partial class ArgonScoreCounter : GameplayScoreCounter, ISerialisableDrawable { + private ArgonScoreTextComponent scoreText = null!; + protected override double RollingDuration => 500; protected override Easing RollingEasing => Easing.OutQuint; @@ -33,13 +36,42 @@ namespace osu.Game.Screens.Play.HUD protected override LocalisableString FormatCount(long count) => count.ToLocalisableString(); - protected override IHasText CreateText() => new ArgonScoreTextComponent(Anchor.TopRight, BeatmapsetsStrings.ShowScoreboardHeadersScore.ToUpper()) + protected override IHasText CreateText() => scoreText = new ArgonScoreTextComponent(Anchor.TopRight, BeatmapsetsStrings.ShowScoreboardHeadersScore.ToUpper()) { - RequiredDisplayDigits = { BindTarget = RequiredDisplayDigits }, WireframeOpacity = { BindTarget = WireframeOpacity }, ShowLabel = { BindTarget = ShowLabel }, }; + public ArgonScoreCounter() + { + RequiredDisplayDigits.BindValueChanged(_ => updateWireframe()); + } + + public override long DisplayedCount + { + get => base.DisplayedCount; + set + { + base.DisplayedCount = value; + updateWireframe(); + } + } + + private void updateWireframe() + { + scoreText.RequiredDisplayDigits.Value = + Math.Max(RequiredDisplayDigits.Value, getDigitsRequiredForDisplayCount()); + } + + private int getDigitsRequiredForDisplayCount() + { + int digitsRequired = 1; + long c = DisplayedCount; + while ((c /= 10) > 0) + digitsRequired++; + return digitsRequired; + } + private partial class ArgonScoreTextComponent : ArgonCounterTextComponent { public ArgonScoreTextComponent(Anchor anchor, LocalisableString? label = null) diff --git a/osu.Game/Screens/Play/HUD/ComboCounter.cs b/osu.Game/Screens/Play/HUD/ComboCounter.cs index 17531281aa..93802e11c2 100644 --- a/osu.Game/Screens/Play/HUD/ComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ComboCounter.cs @@ -11,11 +11,6 @@ namespace osu.Game.Screens.Play.HUD { public bool UsesFixedAnchor { get; set; } - protected ComboCounter() - { - Current.Value = DisplayedCount = 0; - } - protected override double GetProportionalDuration(int currentValue, int newValue) { return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; diff --git a/osu.Game/Screens/Play/HUD/FailingLayer.cs b/osu.Game/Screens/Play/HUD/FailingLayer.cs index 3954e23cbe..2bac7660b3 100644 --- a/osu.Game/Screens/Play/HUD/FailingLayer.cs +++ b/osu.Game/Screens/Play/HUD/FailingLayer.cs @@ -100,11 +100,11 @@ namespace osu.Game.Screens.Play.HUD protected override void Update() { + base.Update(); + double target = Math.Clamp(max_alpha * (1 - Current.Value / low_health_threshold), 0, max_alpha); boxes.Alpha = (float)Interpolation.Lerp(boxes.Alpha, target, Clock.ElapsedFrameTime * 0.01f); - - base.Update(); } } } diff --git a/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs index 9da032e489..28d664a48b 100644 --- a/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs @@ -23,11 +23,11 @@ namespace osu.Game.Screens.Play.HUD { base.LoadComplete(); - AccuracyDisplay.BindValueChanged(mod => + AccuracyDisplay.BindValueChanged(mode => { Current.UnbindBindings(); - switch (mod.NewValue) + switch (mode.NewValue) { case AccuracyDisplayMode.Standard: Current.BindTo(scoreProcessor.Accuracy); diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index 13dc05229e..3ef3dcb417 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -8,6 +8,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; @@ -30,11 +31,13 @@ namespace osu.Game.Screens.Play.HUD public Bindable Current { get; } = new BindableDouble { MinValue = 0, - MaxValue = 1 + MaxValue = 1, }; private BindableNumber health = null!; + protected bool InitialAnimationPlaying => initialIncrease != null; + private ScheduledDelegate? initialIncrease; /// @@ -56,11 +59,6 @@ namespace osu.Game.Screens.Play.HUD // Don't bind directly so we can animate the startup procedure. health = HealthProcessor.Health.GetBoundCopy(); - health.BindValueChanged(h => - { - finishInitialAnimation(); - Current.Value = h.NewValue; - }); if (hudOverlay != null) showHealthBar.BindTo(hudOverlay.ShowHealthBar); @@ -68,12 +66,42 @@ namespace osu.Game.Screens.Play.HUD // this probably shouldn't be operating on `this.` showHealthBar.BindValueChanged(healthBar => this.FadeTo(healthBar.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); + initialHealthValue = health.Value; + if (PlayInitialIncreaseAnimation) startInitialAnimation(); else Current.Value = health.Value; } + private double lastValue; + private double initialHealthValue; + + protected override void Update() + { + base.Update(); + + if (!InitialAnimationPlaying || health.Value != initialHealthValue) + { + Current.Value = health.Value; + + if (initialIncrease != null) + FinishInitialAnimation(Current.Value); + } + + // Health changes every frame in draining situations. + // Manually handle value changes to avoid bindable event flow overhead. + if (!Precision.AlmostEquals(lastValue, Current.Value, 0.001f)) + { + HealthChanged(Current.Value > lastValue); + lastValue = Current.Value; + } + } + + protected virtual void HealthChanged(bool increase) + { + } + private void startInitialAnimation() { if (Current.Value >= health.Value) @@ -90,16 +118,16 @@ namespace osu.Game.Screens.Play.HUD Scheduler.AddOnce(Flash); if (newValue >= health.Value) - finishInitialAnimation(); + FinishInitialAnimation(health.Value); }, increase_delay, true); } - private void finishInitialAnimation() + protected virtual void FinishInitialAnimation(double value) { if (initialIncrease == null) return; - initialIncrease?.Cancel(); + initialIncrease.Cancel(); initialIncrease = null; // aside from the repeating `initialIncrease` scheduled task, diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index eb5221aa45..443863fb2f 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -485,7 +485,14 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } } - public override void Clear() => judgementsContainer.Clear(); + public override void Clear() + { + foreach (var j in judgementsContainer) + { + j.ClearTransforms(); + j.Expire(); + } + } public enum CentreMarkerStyles { diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs index 5793713fca..65f4b50dde 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -63,7 +63,14 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters judgementsFlow.Push(GetColourForHitResult(judgement.Type)); } - public override void Clear() => judgementsFlow.Clear(); + public override void Clear() + { + foreach (var j in judgementsFlow) + { + j.ClearTransforms(); + j.Expire(); + } + } private partial class JudgementFlow : FillFlowContainer { diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index 54ed7ba626..0d60ec4713 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -8,6 +8,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Logging; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -39,6 +40,20 @@ namespace osu.Game.Screens.Play Precision = 0.1, }; + /// + /// Whether the audio playback rate should be validated. + /// Mostly disabled for tests. + /// + internal bool ShouldValidatePlaybackRate { get; init; } + + /// + /// Whether the audio playback is within acceptable ranges. + /// Will become false if audio playback is not going as expected. + /// + public IBindable PlaybackRateValid => playbackRateValid; + + private readonly Bindable playbackRateValid = new Bindable(true); + private readonly WorkingBeatmap beatmap; private Track track; @@ -128,6 +143,7 @@ namespace osu.Game.Screens.Play { // Safety in case the clock is seeked while stopped. LastStopTime = null; + elapsedValidationTime = null; base.Seek(time); } @@ -197,6 +213,56 @@ namespace osu.Game.Screens.Play addAdjustmentsToTrack(); } + protected override void Update() + { + base.Update(); + checkPlaybackValidity(); + } + + #region Clock validation (ensure things are running correctly for local gameplay) + + private double elapsedGameplayClockTime; + private double? elapsedValidationTime; + private int playbackDiscrepancyCount; + + private const int allowed_playback_discrepancies = 5; + + private void checkPlaybackValidity() + { + if (!ShouldValidatePlaybackRate) + return; + + if (GameplayClock.IsRunning) + { + elapsedGameplayClockTime += GameplayClock.ElapsedFrameTime; + + if (elapsedValidationTime == null) + elapsedValidationTime = elapsedGameplayClockTime; + else + elapsedValidationTime += GameplayClock.Rate * Time.Elapsed; + + if (Math.Abs(elapsedGameplayClockTime - elapsedValidationTime!.Value) > 300) + { + if (playbackDiscrepancyCount++ > allowed_playback_discrepancies) + { + if (playbackRateValid.Value) + { + playbackRateValid.Value = false; + Logger.Log("System audio playback is not working as expected. Some online functionality will not work.\n\nPlease check your audio drivers.", level: LogLevel.Important); + } + } + else + { + Logger.Log($"Playback discrepancy detected ({playbackDiscrepancyCount} of allowed {allowed_playback_discrepancies}): {elapsedGameplayClockTime:N1} vs {elapsedValidationTime:N1}"); + } + + elapsedValidationTime = null; + } + } + } + + #endregion + private bool speedAdjustmentsApplied; private void addAdjustmentsToTrack() diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index 88561ada71..2aa2793fd4 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -29,7 +29,13 @@ namespace osu.Game.Screens.Play private SkinnableSound pauseLoop; - protected override Action BackAction => () => InternalButtons.First().TriggerClick(); + protected override Action BackAction => () => + { + if (Buttons.Any()) + Buttons.First().TriggerClick(); + else + OnResume?.Invoke(); + }; [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 1c97efcff7..b87306b9a2 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -109,6 +109,9 @@ namespace osu.Game.Screens.Play [Resolved] private MusicController musicController { get; set; } + [Resolved] + private OsuGameBase game { get; set; } + public GameplayState GameplayState { get; private set; } private Ruleset ruleset; @@ -227,7 +230,8 @@ namespace osu.Game.Screens.Play dependencies.CacheAs(ScoreProcessor); - HealthProcessor = ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); + HealthProcessor = gameplayMods.OfType().FirstOrDefault()?.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); + HealthProcessor ??= ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); HealthProcessor.ApplyBeatmap(playableBeatmap); dependencies.CacheAs(HealthProcessor); @@ -457,6 +461,12 @@ namespace osu.Game.Screens.Play OnRetry = () => Restart(), OnQuit = () => PerformExit(true), }, + new GameplayOffsetControl + { + Margin = new MarginPadding(20), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + } }, }; @@ -725,7 +735,7 @@ namespace osu.Game.Screens.Play } // Only show the completion screen if the player hasn't failed - if (HealthProcessor.HasFailed) + if (GameplayState.HasFailed) return; GameplayState.HasPassed = true; @@ -791,8 +801,6 @@ namespace osu.Game.Screens.Play // This player instance may already be in the process of exiting. return; - Debug.Assert(ScoreProcessor.Rank.Value != ScoreRank.F); - this.Push(CreateResults(prepareScoreForDisplayTask.GetResultSafely())); }, Time.Current + delay, 50); @@ -914,37 +922,44 @@ namespace osu.Game.Screens.Play if (!CheckModsAllowFailure()) return false; - Debug.Assert(!GameplayState.HasFailed); - Debug.Assert(!GameplayState.HasPassed); - Debug.Assert(!GameplayState.HasQuit); + if (Configuration.AllowFailAnimation) + { + Debug.Assert(!GameplayState.HasFailed); + Debug.Assert(!GameplayState.HasPassed); + Debug.Assert(!GameplayState.HasQuit); - GameplayState.HasFailed = true; + GameplayState.HasFailed = true; - updateGameplayState(); + updateGameplayState(); - // There is a chance that we could be in a paused state as the ruleset's internal clock (see FrameStabilityContainer) - // could process an extra frame after the GameplayClock is stopped. - // In such cases we want the fail state to precede a user triggered pause. - if (PauseOverlay.State.Value == Visibility.Visible) - PauseOverlay.Hide(); + // There is a chance that we could be in a paused state as the ruleset's internal clock (see FrameStabilityContainer) + // could process an extra frame after the GameplayClock is stopped. + // In such cases we want the fail state to precede a user triggered pause. + if (PauseOverlay.State.Value == Visibility.Visible) + PauseOverlay.Hide(); - failAnimationContainer.Start(); + failAnimationContainer.Start(); - // Failures can be triggered either by a judgement, or by a mod. - // - // For the case of a judgement, due to ordering considerations, ScoreProcessor will not have received - // the final judgement which triggered the failure yet (see DrawableRuleset.NewResult handling above). - // - // A schedule here ensures that any lingering judgements from the current frame are applied before we - // finalise the score as "failed". - Schedule(() => + // Failures can be triggered either by a judgement, or by a mod. + // + // For the case of a judgement, due to ordering considerations, ScoreProcessor will not have received + // the final judgement which triggered the failure yet (see DrawableRuleset.NewResult handling above). + // + // A schedule here ensures that any lingering judgements from the current frame are applied before we + // finalise the score as "failed". + Schedule(() => + { + ScoreProcessor.FailScore(Score.ScoreInfo); + OnFail(); + + if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) + Restart(true); + }); + } + else { ScoreProcessor.FailScore(Score.ScoreInfo); - OnFail(); - - if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) - Restart(true); - }); + } return true; } @@ -1154,7 +1169,11 @@ namespace osu.Game.Screens.Play /// The . protected virtual Score CreateScore(IBeatmap beatmap) => new Score { - ScoreInfo = new ScoreInfo { User = api.LocalUser.Value }, + ScoreInfo = new ScoreInfo + { + User = api.LocalUser.Value, + ClientVersion = game.Version, + }, }; /// diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs index 122e25f406..466a691118 100644 --- a/osu.Game/Screens/Play/PlayerConfiguration.cs +++ b/osu.Game/Screens/Play/PlayerConfiguration.cs @@ -15,6 +15,12 @@ namespace osu.Game.Screens.Play /// public bool ShowResults { get; set; } = true; + /// + /// Whether the fail animation / screen should be triggered on failing. + /// If false, the score will still be marked as failed but gameplay will continue. + /// + public bool AllowFailAnimation { get; set; } = true; + /// /// Whether the player should be allowed to trigger a restart. /// diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 681189d184..232de53ac3 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -263,10 +263,6 @@ namespace osu.Game.Screens.Play Debug.Assert(CurrentPlayer != null); - var lastScore = CurrentPlayer.Score; - - AudioSettings.ReferenceScore.Value = lastScore?.ScoreInfo; - // prepare for a retry. CurrentPlayer = null; playerConsumed = false; diff --git a/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs b/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs index 010d8115fa..3c79721590 100644 --- a/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.Play.PlayerSettings { public partial class AudioSettings : PlayerSettingsGroup { - public Bindable ReferenceScore { get; } = new Bindable(); + private Bindable referenceScore { get; } = new Bindable(); private readonly PlayerCheckbox beatmapHitsoundsToggle; @@ -26,15 +26,16 @@ namespace osu.Game.Screens.Play.PlayerSettings beatmapHitsoundsToggle = new PlayerCheckbox { LabelText = SkinSettingsStrings.BeatmapHitsounds }, new BeatmapOffsetControl { - ReferenceScore = { BindTarget = ReferenceScore }, + ReferenceScore = { BindTarget = referenceScore }, }, }; } [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(OsuConfigManager config, SessionStatics statics) { beatmapHitsoundsToggle.Current = config.GetBindable(OsuSetting.BeatmapHitsounds); + statics.BindWith(Static.LastLocalUserScore, referenceScore); } } } diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index 840077eb7f..8efb80e771 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -7,8 +7,11 @@ using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -16,8 +19,11 @@ using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; using osu.Game.Localisation; +using osu.Game.Overlays; using osu.Game.Overlays.Settings; +using osu.Game.Overlays.Settings.Sections.Audio; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -26,7 +32,7 @@ using osuTK; namespace osu.Game.Screens.Play.PlayerSettings { - public partial class BeatmapOffsetControl : CompositeDrawable + public partial class BeatmapOffsetControl : CompositeDrawable, IKeyBindingHandler { public Bindable ReferenceScore { get; } = new Bindable(); @@ -48,6 +54,12 @@ namespace osu.Game.Screens.Play.PlayerSettings [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private Player? player { get; set; } + + [Resolved] + private IGameplayClock? gameplayClock { get; set; } + private double lastPlayAverage; private double lastPlayBeatmapOffset; private HitEventTimingDistributionGraph? lastPlayGraph; @@ -88,28 +100,6 @@ namespace osu.Game.Screens.Play.PlayerSettings }; } - public partial class OffsetSliderBar : PlayerSliderBar - { - protected override Drawable CreateControl() => new CustomSliderBar(); - - protected partial class CustomSliderBar : SliderBar - { - public override LocalisableString TooltipText => - Current.Value == 0 - ? LocalisableString.Interpolate($@"{base.TooltipText} ms") - : LocalisableString.Interpolate($@"{base.TooltipText} ms {getEarlyLateText(Current.Value)}"); - - private LocalisableString getEarlyLateText(double value) - { - Debug.Assert(value != 0); - - return value > 0 - ? BeatmapOffsetControlStrings.HitObjectsAppearEarlier - : BeatmapOffsetControlStrings.HitObjectsAppearLater; - } - } - } - protected override void LoadComplete() { base.LoadComplete(); @@ -170,11 +160,11 @@ namespace osu.Game.Screens.Play.PlayerSettings // Apply to all difficulties in a beatmap set for now (they generally always share timing). foreach (var b in setInfo.Beatmaps) { - BeatmapUserSettings settings = b.UserSettings; + BeatmapUserSettings userSettings = b.UserSettings; double val = Current.Value; - if (settings.Offset != val) - settings.Offset = val; + if (userSettings.Offset != val) + userSettings.Offset = val; } }); } @@ -187,6 +177,9 @@ namespace osu.Game.Screens.Play.PlayerSettings if (score.NewValue == null) return; + if (!score.NewValue.BeatmapInfo.AsNonNull().Equals(beatmap.Value.BeatmapInfo)) + return; + if (score.NewValue.Mods.Any(m => !m.UserPlayable || m is IHasNoTimedInputs)) return; @@ -222,6 +215,8 @@ namespace osu.Game.Screens.Play.PlayerSettings lastPlayAverage = average; lastPlayBeatmapOffset = Current.Value; + LinkFlowContainer globalOffsetText; + referenceScoreContainer.AddRange(new Drawable[] { lastPlayGraph = new HitEventTimingDistributionGraph(hitEvents) @@ -235,13 +230,91 @@ namespace osu.Game.Screens.Play.PlayerSettings Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay, Action = () => Current.Value = lastPlayBeatmapOffset - lastPlayAverage }, + globalOffsetText = new LinkFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } }); + + if (settings != null) + { + globalOffsetText.AddText("You can also "); + globalOffsetText.AddLink("adjust the global offset", () => settings.ShowAtControl()); + globalOffsetText.AddText(" based off this play."); + } } + [Resolved] + private SettingsOverlay? settings { get; set; } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); beatmapOffsetSubscription?.Dispose(); } + + public bool OnPressed(KeyBindingPressEvent e) + { + // General limitations to ensure players don't do anything too weird. + // These match stable for now. + if (player is SubmittingPlayer) + { + // TODO: the blocking conditions should probably display a message. + if (player?.IsBreakTime.Value == false && gameplayClock?.CurrentTime - gameplayClock?.StartTime > 10000) + return false; + + if (gameplayClock?.IsPaused.Value == true) + return false; + } + + // To match stable, this should adjust by 5 ms, or 1 ms when holding alt. + // But that is hard to make work with global actions due to the operating mode. + // Let's use the more precise as a default for now. + const double amount = 1; + + switch (e.Action) + { + case GlobalAction.IncreaseOffset: + Current.Value += amount; + return true; + + case GlobalAction.DecreaseOffset: + Current.Value -= amount; + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + public static LocalisableString GetOffsetExplanatoryText(double offset) + { + return offset == 0 + ? LocalisableString.Interpolate($@"{offset:0.0} ms") + : LocalisableString.Interpolate($@"{offset:0.0} ms {getEarlyLateText(offset)}"); + + LocalisableString getEarlyLateText(double value) + { + Debug.Assert(value != 0); + + return value > 0 + ? BeatmapOffsetControlStrings.HitObjectsAppearEarlier + : BeatmapOffsetControlStrings.HitObjectsAppearLater; + } + } + + public partial class OffsetSliderBar : PlayerSliderBar + { + protected override Drawable CreateControl() => new CustomSliderBar(); + + protected partial class CustomSliderBar : SliderBar + { + public override LocalisableString TooltipText => GetOffsetExplanatoryText(Current.Value); + } + } } } diff --git a/osu.Game/Screens/Play/SaveFailedScoreButton.cs b/osu.Game/Screens/Play/SaveFailedScoreButton.cs index 0a2696339c..b97c140250 100644 --- a/osu.Game/Screens/Play/SaveFailedScoreButton.cs +++ b/osu.Game/Screens/Play/SaveFailedScoreButton.cs @@ -102,6 +102,9 @@ namespace osu.Game.Screens.Play public bool OnPressed(KeyBindingPressEvent e) { + if (e.Repeat) + return false; + switch (e.Action) { case GlobalAction.SaveReplay: diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index d1404ac184..2faead0ee1 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -25,6 +25,8 @@ namespace osu.Game.Screens.Play private readonly Score score; + public override bool AllowBackButton => true; + protected override bool CheckModsAllowFailure() { if (!allowFail) diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 785164178a..04abb6162f 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; @@ -37,6 +38,10 @@ namespace osu.Game.Screens.Play [Resolved] private SpectatorClient spectatorClient { get; set; } + [Resolved] + private SessionStatics statics { get; set; } + + private readonly object scoreSubmissionLock = new object(); private TaskCompletionSource scoreSubmissionSource; protected SubmittingPlayer(PlayerConfiguration configuration = null) @@ -56,6 +61,11 @@ namespace osu.Game.Screens.Play AddInternal(new PlayerTouchInputDetector()); } + protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart) + { + ShouldValidatePlaybackRate = true, + }; + protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); @@ -176,6 +186,7 @@ namespace osu.Game.Screens.Play { bool exiting = base.OnExiting(e); submitFromFailOrQuit(); + statics.SetValue(Static.LastLocalUserScore, Score?.ScoreInfo.DeepClone()); return exiting; } @@ -208,6 +219,14 @@ namespace osu.Game.Screens.Play private Task submitScore(Score score) { + var masterClock = GameplayClockContainer as MasterGameplayClockContainer; + + if (masterClock?.PlaybackRateValid.Value != true) + { + Logger.Log("Score submission cancelled due to audio playback rate discrepancy."); + return Task.CompletedTask; + } + // token may be null if the request failed but gameplay was still allowed (see HandleTokenRetrievalFailure). if (token == null) { @@ -215,16 +234,19 @@ namespace osu.Game.Screens.Play return Task.CompletedTask; } - if (scoreSubmissionSource != null) - return scoreSubmissionSource.Task; + lock (scoreSubmissionLock) + { + if (scoreSubmissionSource != null) + return scoreSubmissionSource.Task; + + scoreSubmissionSource = new TaskCompletionSource(); + } // if the user never hit anything, this score should not be counted in any way. if (!score.ScoreInfo.Statistics.Any(s => s.Key.IsHit() && s.Value > 0)) return Task.CompletedTask; Logger.Log($"Beginning score submission (token:{token.Value})..."); - - scoreSubmissionSource = new TaskCompletionSource(); var request = CreateSubmissionRequest(score, token.Value); request.Success += s => diff --git a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs index 73b9897096..762be61853 100644 --- a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs @@ -37,7 +37,6 @@ namespace osu.Game.Screens.Ranking.Statistics RelativeSizeAxes = Axes.X, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Width = 0.5f, StatisticsUpdate = { BindTarget = StatisticsUpdate } })).ToArray(); } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index eb47a7201a..4408634787 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -64,7 +64,7 @@ namespace osu.Game.Screens.Select /// /// The total count of non-filtered beatmaps displayed. /// - public int CountDisplayed => beatmapSets.Where(s => !s.Filtered.Value).Sum(s => s.Beatmaps.Count(b => !b.Filtered.Value)); + public int CountDisplayed => beatmapSets.Where(s => !s.Filtered.Value).Sum(s => s.TotalItemsNotFiltered); /// /// The currently selected beatmap set. @@ -96,18 +96,19 @@ namespace osu.Game.Screens.Select /// /// Extend the range to retain already loaded pooled drawables. /// - private const float distance_offscreen_before_unload = 1024; + private const float distance_offscreen_before_unload = 2048; /// /// Extend the range to update positions / retrieve pooled drawables outside of visible range. /// - private const float distance_offscreen_to_preload = 512; // todo: adjust this appropriately once we can make set panel contents load while off-screen. + private const float distance_offscreen_to_preload = 768; /// /// Whether carousel items have completed asynchronously loaded. /// public bool BeatmapSetsLoaded { get; private set; } + [Cached] protected readonly CarouselScrollContainer Scroll; private readonly NoResultsPlaceholder noResultsPlaceholder; @@ -168,7 +169,10 @@ namespace osu.Game.Screens.Select applyActiveCriteria(false); if (loadedTestBeatmaps) - signalBeatmapsLoaded(); + { + invalidateAfterChange(); + BeatmapSetsLoaded = true; + } // Restore selection if (selectedBeatmapBefore != null && newRoot.BeatmapSetsByID.TryGetValue(selectedBeatmapBefore.BeatmapSet!.ID, out var newSelectionCandidates)) @@ -266,8 +270,30 @@ namespace osu.Game.Screens.Select if (changes == null) return; - foreach (int i in changes.InsertedIndices) - removeBeatmapSet(sender[i].ID); + var removeableSets = changes.InsertedIndices.Select(i => sender[i].ID).ToHashSet(); + + // This schedule is required to retain selection of beatmaps over an ImportAsUpdate operation. + // This is covered by TestPlaySongSelect.TestSelectionRetainedOnBeatmapUpdate. + // + // In short, we have specialised logic in `beatmapSetsChanged` (directly below) to infer that an + // update operation has occurred. For this to work, we need to confirm the `DeletePending` flag + // of the current selection. + // + // If we don't schedule the following code, it is possible for the `deleteBeatmapSetsChanged` handler + // to be invoked before the `beatmapSetsChanged` handler (realm call order seems non-deterministic) + // which will lead to the currently selected beatmap changing via `CarouselGroupEagerSelect`. + // + // We need a better path forward here. A few ideas: + // - Avoid the necessity of having realm subscriptions on deleted/hidden items, maybe by storing all guids in realm + // to a local list so we can better look them up on receiving `DeletedIndices`. + // - Add a new property on `BeatmapSetInfo` to link to the pre-update set, and use that to handle the update case. + Schedule(() => + { + foreach (var set in removeableSets) + removeBeatmapSet(set); + + invalidateAfterChange(); + }); } private void beatmapSetsChanged(IRealmCollection sender, ChangeSet? changes) @@ -276,6 +302,9 @@ namespace osu.Game.Screens.Select if (loadedTestBeatmaps) return; + var setsRequiringUpdate = new HashSet(); + var setsRequiringRemoval = new HashSet(); + if (changes == null) { // During initial population, we must manually account for the fact that our original query was done on an async thread. @@ -289,64 +318,80 @@ namespace osu.Game.Screens.Select foreach (var id in realmSets) { if (!root.BeatmapSetsByID.ContainsKey(id)) - UpdateBeatmapSet(realm.Realm.Find(id)!.Detach()); + setsRequiringUpdate.Add(realm.Realm.Find(id)!.Detach()); } foreach (var id in root.BeatmapSetsByID.Keys) { if (!realmSets.Contains(id)) - removeBeatmapSet(id); + setsRequiringRemoval.Add(id); } + } + else + { + foreach (int i in changes.NewModifiedIndices) + setsRequiringUpdate.Add(sender[i].Detach()); - signalBeatmapsLoaded(); - return; + foreach (int i in changes.InsertedIndices) + setsRequiringUpdate.Add(sender[i].Detach()); } - foreach (int i in changes.NewModifiedIndices) - UpdateBeatmapSet(sender[i].Detach()); - - foreach (int i in changes.InsertedIndices) - UpdateBeatmapSet(sender[i].Detach()); - - if (changes.DeletedIndices.Length > 0 && SelectedBeatmapInfo != null) + // All local operations must be scheduled. + // + // If we don't schedule, beatmaps getting changed while song select is suspended (ie. last played being updated) + // will cause unexpected sounds and operations to occur in the background. + Schedule(() => { - // If SelectedBeatmapInfo is non-null, the set should also be non-null. - Debug.Assert(SelectedBeatmapSet != null); - - // To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions. - // When an update occurs, the previous beatmap set is either soft or hard deleted. - // Check if the current selection was potentially deleted by re-querying its validity. - bool selectedSetMarkedDeleted = realm.Run(r => r.Find(SelectedBeatmapSet.ID))?.DeletePending != false; - - int[] modifiedAndInserted = changes.NewModifiedIndices.Concat(changes.InsertedIndices).ToArray(); - - if (selectedSetMarkedDeleted && modifiedAndInserted.Any()) + try { - // If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices. - // This relies on the full update operation being in a single transaction, so please don't change that. - foreach (int i in modifiedAndInserted) + foreach (var set in setsRequiringRemoval) + removeBeatmapSet(set); + + foreach (var set in setsRequiringUpdate) + updateBeatmapSet(set); + + if (changes?.DeletedIndices.Length > 0 && SelectedBeatmapInfo != null) { - var beatmapSetInfo = sender[i]; + // If SelectedBeatmapInfo is non-null, the set should also be non-null. + Debug.Assert(SelectedBeatmapSet != null); - foreach (var beatmapInfo in beatmapSetInfo.Beatmaps) + // To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions. + // When an update occurs, the previous beatmap set is either soft or hard deleted. + // Check if the current selection was potentially deleted by re-querying its validity. + bool selectedSetMarkedDeleted = realm.Run(r => r.Find(SelectedBeatmapSet.ID)?.DeletePending != false); + + if (selectedSetMarkedDeleted && setsRequiringUpdate.Any()) { - if (!((IBeatmapMetadataInfo)beatmapInfo.Metadata).Equals(SelectedBeatmapInfo.Metadata)) - continue; - - // Best effort matching. We can't use ID because in the update flow a new version will get its own GUID. - if (beatmapInfo.DifficultyName == SelectedBeatmapInfo.DifficultyName) + // If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices. + // This relies on the full update operation being in a single transaction, so please don't change that. + foreach (var set in setsRequiringUpdate) { - SelectBeatmap(beatmapInfo); - return; + foreach (var beatmapInfo in set.Beatmaps) + { + if (!((IBeatmapMetadataInfo)beatmapInfo.Metadata).Equals(SelectedBeatmapInfo.Metadata)) + continue; + + // Best effort matching. We can't use ID because in the update flow a new version will get its own GUID. + if (beatmapInfo.DifficultyName == SelectedBeatmapInfo.DifficultyName) + { + SelectBeatmap(beatmapInfo); + return; + } + } } + + // If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed. + // Let's attempt to follow set-level selection anyway. + SelectBeatmap(setsRequiringUpdate.First().Beatmaps.First()); } } - - // If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed. - // Let's attempt to follow set-level selection anyway. - SelectBeatmap(sender[modifiedAndInserted.First()].Beatmaps.First()); } - } + finally + { + BeatmapSetsLoaded = true; + invalidateAfterChange(); + } + }); } private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes) @@ -355,6 +400,8 @@ namespace osu.Game.Screens.Select if (changes == null) return; + bool changed = false; + foreach (int i in changes.InsertedIndices) { var beatmapInfo = sender[i]; @@ -367,17 +414,24 @@ namespace osu.Game.Screens.Select if (root.BeatmapSetsByID.TryGetValue(beatmapSet.ID, out var existingSets) && existingSets.SelectMany(s => s.Beatmaps).All(b => b.BeatmapInfo.ID != beatmapInfo.ID)) { - UpdateBeatmapSet(beatmapSet.Detach()); + updateBeatmapSet(beatmapSet.Detach()); + changed = true; } } + + if (changed) + invalidateAfterChange(); } private IQueryable getBeatmapSets(Realm realm) => realm.All().Where(s => !s.DeletePending && !s.Protected); - public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) => + public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => + { removeBeatmapSet(beatmapSet.ID); + invalidateAfterChange(); + }); - private void removeBeatmapSet(Guid beatmapSetID) => Schedule(() => + private void removeBeatmapSet(Guid beatmapSetID) { if (!root.BeatmapSetsByID.TryGetValue(beatmapSetID, out var existingSets)) return; @@ -392,41 +446,23 @@ namespace osu.Game.Screens.Select root.RemoveItem(set); } - - itemsCache.Invalidate(); - - if (!Scroll.UserScrolling) - ScrollToSelected(true); - - BeatmapSetsChanged?.Invoke(); - }); + } public void UpdateBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => { - Guid? previouslySelectedID = null; + updateBeatmapSet(beatmapSet); + invalidateAfterChange(); + }); + private void updateBeatmapSet(BeatmapSetInfo beatmapSet) + { originalBeatmapSetsDetached.RemoveAll(set => set.ID == beatmapSet.ID); originalBeatmapSetsDetached.Add(beatmapSet.Detach()); - // If the selected beatmap is about to be removed, store its ID so it can be re-selected if required - if (selectedBeatmapSet?.BeatmapSet.ID == beatmapSet.ID) - previouslySelectedID = selectedBeatmap?.BeatmapInfo.ID; - - var removedSets = root.RemoveItemsByID(beatmapSet.ID); - - foreach (var removedSet in removedSets) - { - // If we don't remove this here, it may remain in a hidden state until scrolled off screen. - // Doesn't really affect anything during actual user interaction, but makes testing annoying. - var removedDrawable = Scroll.FirstOrDefault(c => c.Item == removedSet); - if (removedDrawable != null) - expirePanelImmediately(removedDrawable); - } + var newSets = new List(); if (beatmapsSplitOut) { - var newSets = new List(); - foreach (var beatmap in beatmapSet.Beatmaps) { var newSet = createCarouselSet(new BeatmapSetInfo(new[] { beatmap }) @@ -437,18 +473,7 @@ namespace osu.Game.Screens.Select }); if (newSet != null) - { newSets.Add(newSet); - root.AddItem(newSet); - } - } - - // check if we can/need to maintain our current selection. - if (previouslySelectedID != null) - { - var toSelect = newSets.FirstOrDefault(s => s.Beatmaps.Any(b => b.BeatmapInfo.ID == previouslySelectedID)) - ?? newSets.FirstOrDefault(); - select(toSelect); } } else @@ -456,22 +481,20 @@ namespace osu.Game.Screens.Select var newSet = createCarouselSet(beatmapSet); if (newSet != null) - { - root.AddItem(newSet); - - // check if we can/need to maintain our current selection. - if (previouslySelectedID != null) - select((CarouselItem?)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet); - } + newSets.Add(newSet); } - itemsCache.Invalidate(); + var removedSets = root.ReplaceItem(beatmapSet, newSets); - if (!Scroll.UserScrolling) - ScrollToSelected(true); - - BeatmapSetsChanged?.Invoke(); - }); + // If we don't remove these here, it may remain in a hidden state until scrolled off screen. + // Doesn't really affect anything during actual user interaction, but makes testing annoying. + foreach (var removedSet in removedSets) + { + var removedDrawable = Scroll.FirstOrDefault(c => c.Item == removedSet); + if (removedDrawable != null) + expirePanelImmediately(removedDrawable); + } + } /// /// Selects a given beatmap on the carousel. @@ -621,7 +644,7 @@ namespace osu.Game.Screens.Select while (randomSelectedBeatmaps.Any()) { var beatmap = randomSelectedBeatmaps[^1]; - randomSelectedBeatmaps.Remove(beatmap); + randomSelectedBeatmaps.RemoveAt(randomSelectedBeatmaps.Count - 1); if (!beatmap.Filtered.Value && beatmap.BeatmapInfo.BeatmapSet?.DeletePending != true) { @@ -748,15 +771,14 @@ namespace osu.Game.Screens.Select } } - private void signalBeatmapsLoaded() + private void invalidateAfterChange() { - if (!BeatmapSetsLoaded) - { - BeatmapSetsChanged?.Invoke(); - BeatmapSetsLoaded = true; - } - itemsCache.Invalidate(); + + if (!Scroll.UserScrolling) + ScrollToSelected(true); + + BeatmapSetsChanged?.Invoke(); } private float? scrollTarget; @@ -1163,6 +1185,43 @@ namespace osu.Game.Screens.Select base.AddItem(i); } + /// + /// A special method to handle replace operations (general for updating a beatmap). + /// Avoids event-driven selection flip-flopping during the remove/add process. + /// + /// The beatmap set to be replaced. + /// All new items to replace the removed beatmap set. + /// All removed items, for any further processing. + public IEnumerable ReplaceItem(BeatmapSetInfo oldItem, List newItems) + { + var previousSelection = (LastSelected as CarouselBeatmapSet)?.Beatmaps + .FirstOrDefault(s => s.State.Value == CarouselItemState.Selected) + ?.BeatmapInfo; + + bool wasSelected = previousSelection?.BeatmapSet?.ID == oldItem.ID; + + // Without doing this, the removal of the old beatmap will cause carousel's eager selection + // logic to invoke, causing one unnecessary selection. + DisableSelection = true; + var removedSets = RemoveItemsByID(oldItem.ID); + DisableSelection = false; + + foreach (var set in newItems) + AddItem(set); + + // Check if we can/need to maintain our current selection. + if (wasSelected) + { + CarouselBeatmap? matchingBeatmap = newItems.SelectMany(s => s.Beatmaps) + .FirstOrDefault(b => b.BeatmapInfo.ID == previousSelection?.ID); + + if (matchingBeatmap != null) + matchingBeatmap.State.Value = CarouselItemState.Selected; + } + + return removedSets; + } + public IEnumerable RemoveItemsByID(Guid beatmapSetID) { if (BeatmapSetsByID.TryGetValue(beatmapSetID, out var carouselBeatmapSets)) @@ -1193,7 +1252,7 @@ namespace osu.Game.Screens.Select } } - protected partial class CarouselScrollContainer : UserTrackingScrollContainer + public partial class CarouselScrollContainer : UserTrackingScrollContainer { private bool rightMouseScrollBlocked; diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 179323176a..dec2c1c1de 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -3,7 +3,6 @@ using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -19,7 +18,6 @@ using osu.Game.Overlays.BeatmapSet; using osu.Game.Resources.Localisation.Web; using osu.Game.Screens.Select.Details; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.Select { @@ -28,7 +26,6 @@ namespace osu.Game.Screens.Select private const float spacing = 10; private const float transition_duration = 250; - private readonly AdvancedStats advanced; private readonly UserRatings ratingsDisplay; private readonly MetadataSection description, source, tags; private readonly Container failRetryContainer; @@ -68,12 +65,15 @@ namespace osu.Game.Screens.Select public BeatmapDetails() { + CornerRadius = 10; + Masking = true; + Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f), + Colour = Colour4.Black.Opacity(0.3f), }, new Container { @@ -109,12 +109,6 @@ namespace osu.Game.Screens.Select Padding = new MarginPadding { Right = spacing / 2 }, Children = new[] { - new DetailBox().WithChild(advanced = new AdvancedStats - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = spacing, Top = spacing * 2, Bottom = spacing }, - }), new DetailBox().WithChild(new OnlineViewContainer(string.Empty) { RelativeSizeAxes = Axes.X, @@ -129,7 +123,8 @@ namespace osu.Game.Screens.Select }, new OsuScrollContainer { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + Height = 250, Width = 0.5f, ScrollbarVisible = false, Padding = new MarginPadding { Left = spacing / 2 }, @@ -180,7 +175,6 @@ namespace osu.Game.Screens.Select private void updateStatistics() { - advanced.BeatmapInfo = BeatmapInfo; description.Metadata = BeatmapInfo?.DifficultyName ?? string.Empty; source.Metadata = BeatmapInfo?.Metadata.Source ?? string.Empty; tags.Metadata = BeatmapInfo?.Metadata.Tags ?? string.Empty; @@ -279,11 +273,6 @@ namespace osu.Game.Screens.Select InternalChildren = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f), - }, content = new Container { RelativeSizeAxes = Axes.X, diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 8bbf569566..c69cd6ead6 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -61,7 +61,7 @@ namespace osu.Game.Screens.Select { Type = EdgeEffectType.Glow, Colour = new Color4(130, 204, 255, 150), - Radius = 20, + Radius = 15, Roundness = 15, }; } @@ -161,6 +161,7 @@ namespace osu.Game.Screens.Select private ILocalisedBindableString artistBinding; private FillFlowContainer infoLabelContainer; private Container bpmLabelContainer; + private Container lengthLabelContainer; private readonly WorkingBeatmap working; private readonly RulesetInfo ruleset; @@ -305,7 +306,7 @@ namespace osu.Game.Screens.Select }, infoLabelContainer = new FillFlowContainer { - Margin = new MarginPadding { Top = 20 }, + Margin = new MarginPadding { Top = 8 }, Spacing = new Vector2(20, 0), AutoSizeAxes = Axes.Both, } @@ -341,10 +342,10 @@ namespace osu.Game.Screens.Select { settingChangeTracker?.Dispose(); - refreshBPMLabel(); + refreshBPMAndLengthLabel(); settingChangeTracker = new ModSettingChangeTracker(m.NewValue); - settingChangeTracker.SettingChanged += _ => refreshBPMLabel(); + settingChangeTracker.SettingChanged += _ => refreshBPMAndLengthLabel(); }, true); } @@ -370,12 +371,10 @@ namespace osu.Game.Screens.Select infoLabelContainer.Children = new Drawable[] { - new InfoLabel(new BeatmapStatistic + lengthLabelContainer = new Container { - Name = BeatmapsetsStrings.ShowStatsTotalLength(playableBeatmap.CalculateDrainLength().ToFormattedDuration()), - CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), - Content = working.BeatmapInfo.Length.ToFormattedDuration().ToString(), - }), + AutoSizeAxes = Axes.Both, + }, bpmLabelContainer = new Container { AutoSizeAxes = Axes.Both, @@ -394,7 +393,7 @@ namespace osu.Game.Screens.Select } } - private void refreshBPMLabel() + private void refreshBPMAndLengthLabel() { var beatmap = working.Beatmap; @@ -420,6 +419,16 @@ namespace osu.Game.Screens.Select CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Bpm), Content = labelText }); + + double drainLength = Math.Round(beatmap.CalculateDrainLength() / rate); + double hitLength = Math.Round(beatmap.BeatmapInfo.Length / rate); + + lengthLabelContainer.Child = new InfoLabel(new BeatmapStatistic + { + Name = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()), + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), + Content = hitLength.ToFormattedDuration().ToString(), + }); } private Drawable getMapper(BeatmapMetadata metadata) diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs index 67822a27ee..6d2e938fb7 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs @@ -45,7 +45,7 @@ namespace osu.Game.Screens.Select.Carousel .ForEach(AddItem); } - protected override CarouselItem? GetNextToSelect() + public override CarouselItem? GetNextToSelect() { if (LastSelected == null || LastSelected.Filtered.Value) { diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs index c353ee98ae..b2ca117cec 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs @@ -14,6 +14,8 @@ namespace osu.Game.Screens.Select.Carousel public IReadOnlyList Items => items; + public int TotalItemsNotFiltered { get; private set; } + private readonly List items = new List(); /// @@ -31,6 +33,9 @@ namespace osu.Game.Screens.Select.Carousel { items.Remove(i); + if (!i.Filtered.Value) + TotalItemsNotFiltered--; + // it's important we do the deselection after removing, so any further actions based on // State.ValueChanged make decisions post-removal. i.State.Value = CarouselItemState.Collapsed; @@ -55,6 +60,9 @@ namespace osu.Game.Screens.Select.Carousel // criteria may be null for initial population. the filtering will be applied post-add. items.Add(i); } + + if (!i.Filtered.Value) + TotalItemsNotFiltered++; } public CarouselGroup(List? items = null) @@ -84,10 +92,17 @@ namespace osu.Game.Screens.Select.Carousel { base.Filter(criteria); - items.ForEach(c => c.Filter(criteria)); + TotalItemsNotFiltered = 0; + + foreach (var c in items) + { + c.Filter(criteria); + if (!c.Filtered.Value) + TotalItemsNotFiltered++; + } // Sorting is expensive, so only perform if it's actually changed. - if (lastCriteria?.Sort != criteria.Sort) + if (lastCriteria?.RequiresSorting(criteria) != false) { criteriaComparer = Comparer.Create((x, y) => { diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs index 7f90e05744..cf4ba5924f 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs @@ -36,13 +36,13 @@ namespace osu.Game.Screens.Select.Carousel /// items have been filtered. This bool will be true during the base /// operation. /// - private bool filteringItems; + protected bool DisableSelection; public override void Filter(FilterCriteria criteria) { - filteringItems = true; + DisableSelection = true; base.Filter(criteria); - filteringItems = false; + DisableSelection = false; attemptSelection(); } @@ -95,7 +95,7 @@ namespace osu.Game.Screens.Select.Carousel private void attemptSelection() { - if (filteringItems) return; + if (DisableSelection) return; // we only perform eager selection if we are a currently selected group. if (State.Value != CarouselItemState.Selected) return; @@ -110,7 +110,7 @@ namespace osu.Game.Screens.Select.Carousel /// Finds the item this group would select next if it attempted selection /// /// An unfiltered item nearest to the last selected one or null if all items are filtered - protected virtual CarouselItem? GetNextToSelect() + public virtual CarouselItem? GetNextToSelect() { if (Items.Count == 0) return null; diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 3dfd801f02..baf0a14062 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -27,6 +27,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; @@ -57,6 +58,8 @@ namespace osu.Game.Screens.Select.Carousel private StarCounter starCounter = null!; private DifficultyIcon difficultyIcon = null!; + private OsuSpriteText keyCountText = null!; + [Resolved] private BeatmapSetOverlay? beatmapOverlay { get; set; } @@ -69,6 +72,9 @@ namespace osu.Game.Screens.Select.Carousel [Resolved] private RealmAccess realm { get; set; } = null!; + [Resolved] + private IBindable ruleset { get; set; } = null!; + private IBindable starDifficultyBindable = null!; private CancellationTokenSource? starDifficultyCancellationSource; @@ -85,7 +91,7 @@ namespace osu.Game.Screens.Select.Carousel if (songSelect != null) { - mainMenuItems = songSelect.CreateForwardNavigationMenuItemsForBeatmap(beatmapInfo); + mainMenuItems = songSelect.CreateForwardNavigationMenuItemsForBeatmap(() => beatmapInfo); selectRequested = b => songSelect.FinaliseSelection(b); } @@ -133,6 +139,13 @@ namespace osu.Game.Screens.Select.Carousel AutoSizeAxes = Axes.Both, Children = new[] { + keyCountText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 20), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Alpha = 0, + }, new OsuSpriteText { Text = beatmapInfo.DifficultyName, @@ -167,6 +180,13 @@ namespace osu.Game.Screens.Select.Carousel }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => updateKeyCount()); + } + protected override void Selected() { base.Selected(); @@ -216,11 +236,31 @@ namespace osu.Game.Screens.Select.Carousel if (d.NewValue != null) difficultyIcon.Current.Value = d.NewValue.Value; }, true); + + updateKeyCount(); } base.ApplyState(); } + private void updateKeyCount() + { + if (Item?.State.Value == CarouselItemState.Collapsed) + return; + + if (ruleset.Value.OnlineID == 3) + { + // Account for mania differences locally for now. + // Eventually this should be handled in a more modular way, allowing rulesets to add more information to the panel. + ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + + keyCountText.Alpha = 1; + keyCountText.Text = $"[{legacyRuleset.GetKeyCount(beatmapInfo)}K]"; + } + else + keyCountText.Alpha = 0; + } + public MenuItem[] ContextMenuItems { get diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index dd711b2513..369db37e63 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -5,11 +5,14 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -44,6 +47,10 @@ namespace osu.Game.Screens.Select.Carousel private Task? beatmapsLoadTask; + private MenuItem[]? mainMenuItems; + + private double timeSinceUnpool; + [Resolved] private BeatmapManager manager { get; set; } = null!; @@ -52,13 +59,17 @@ namespace osu.Game.Screens.Select.Carousel base.FreeAfterUse(); Item = null; + timeSinceUnpool = 0; ClearTransforms(); } [BackgroundDependencyLoader] - private void load(BeatmapSetOverlay? beatmapOverlay) + private void load(BeatmapSetOverlay? beatmapOverlay, SongSelect? songSelect) { + if (songSelect != null) + mainMenuItems = songSelect.CreateForwardNavigationMenuItemsForBeatmap(() => (((CarouselBeatmapSet)Item!).GetNextToSelect() as CarouselBeatmap)!.BeatmapInfo); + restoreHiddenRequested = s => { foreach (var b in s.Beatmaps) @@ -87,13 +98,21 @@ namespace osu.Game.Screens.Select.Carousel // algorithm for this is taken from ScrollContainer. // while it doesn't necessarily need to match 1:1, as we are emulating scroll in some cases this feels most correct. Y = (float)Interpolation.Lerp(targetY, Y, Math.Exp(-0.01 * Time.Elapsed)); + + loadContentIfRequired(); } + private CancellationTokenSource? loadCancellation; + protected override void UpdateItem() { + loadCancellation?.Cancel(); + loadCancellation = null; + base.UpdateItem(); Content.Clear(); + Header.Clear(); beatmapContainer = null; beatmapsLoadTask = null; @@ -102,32 +121,8 @@ namespace osu.Game.Screens.Select.Carousel return; beatmapSet = ((CarouselBeatmapSet)Item).BeatmapSet; - - DelayedLoadWrapper background; - DelayedLoadWrapper mainFlow; - - Header.Children = new Drawable[] - { - // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). - background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) - { - RelativeSizeAxes = Axes.Both, - }, 200) - { - RelativeSizeAxes = Axes.Both - }, - mainFlow = new DelayedLoadWrapper(() => new SetPanelContent((CarouselBeatmapSet)Item), 50) - { - RelativeSizeAxes = Axes.Both - }, - }; - - background.DelayedLoadComplete += fadeContentIn; - mainFlow.DelayedLoadComplete += fadeContentIn; } - private void fadeContentIn(Drawable d) => d.FadeInFromZero(150); - protected override void Deselected() { base.Deselected(); @@ -185,6 +180,56 @@ namespace osu.Game.Screens.Select.Carousel } } + [Resolved] + private BeatmapCarousel.CarouselScrollContainer scrollContainer { get; set; } = null!; + + private void loadContentIfRequired() + { + Quad containingSsdq = scrollContainer.ScreenSpaceDrawQuad; + + // Using DelayedLoadWrappers would only allow us to load content when on screen, but we want to preload while off-screen + // to provide a better user experience. + + // This is tracking time that this drawable is updating since the last pool. + // This is intended to provide a debounce so very fast scrolls (from one end to the other of the carousel) + // don't cause huge overheads. + // + // We increase the delay based on distance from centre, so the beatmaps the user is currently looking at load first. + float timeUpdatingBeforeLoad = 50 + Math.Abs(containingSsdq.Centre.Y - ScreenSpaceDrawQuad.Centre.Y) / containingSsdq.Height * 100; + + Debug.Assert(Item != null); + + // A load is already in progress if the cancellation token is non-null. + if (loadCancellation != null) + return; + + timeSinceUnpool += Time.Elapsed; + + // We only trigger a load after this set has been in an updating state for a set amount of time. + if (timeSinceUnpool <= timeUpdatingBeforeLoad) + return; + + loadCancellation = new CancellationTokenSource(); + + LoadComponentsAsync(new CompositeDrawable[] + { + // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). + new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) + { + RelativeSizeAxes = Axes.Both, + }, + new SetPanelContent((CarouselBeatmapSet)Item) + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both, + } + }, drawables => + { + Header.AddRange(drawables); + drawables.ForEach(d => d.FadeInFromZero(150)); + }, loadCancellation.Token); + } + private void updateBeatmapYPositions() { if (beatmapContainer == null) @@ -222,6 +267,9 @@ namespace osu.Game.Screens.Select.Carousel if (Item?.State.Value == CarouselItemState.NotSelected) items.Add(new OsuMenuItem("Expand", MenuItemType.Highlighted, () => Item.State.Value = CarouselItemState.Selected)); + if (mainMenuItems != null) + items.AddRange(mainMenuItems); + if (beatmapSet.OnlineID > 0 && viewDetails != null) items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineID))); diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index a383298faa..0d68a0ec3c 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -25,10 +26,11 @@ using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; +using osu.Game.Overlays.Mods; namespace osu.Game.Screens.Select.Details { - public partial class AdvancedStats : Container + public partial class AdvancedStats : Container, IHasCustomTooltip { [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } @@ -44,6 +46,9 @@ namespace osu.Game.Screens.Select.Details protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; private readonly StatisticRow starDifficulty; + public ITooltip GetCustomTooltip() => new AdjustedAttributesTooltip(); + public AdjustedAttributesTooltip.Data TooltipContent { get; private set; } + private IBeatmapInfo beatmapInfo; public IBeatmapInfo BeatmapInfo @@ -59,21 +64,67 @@ namespace osu.Game.Screens.Select.Details } } - public AdvancedStats() + public AdvancedStats(int columns = 1) { - Child = new FillFlowContainer + switch (columns) { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new[] - { - FirstValue = new StatisticRow(), // circle size/key amount - HpDrain = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsDrain }, - Accuracy = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsAccuracy }, - ApproachRate = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsAr }, - starDifficulty = new StatisticRow(10, true) { Title = BeatmapsetsStrings.ShowStatsStars }, - }, - }; + case 1: + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + FirstValue = new StatisticRow(), // circle size/key amount + HpDrain = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsDrain }, + Accuracy = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsAccuracy }, + ApproachRate = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsAr }, + starDifficulty = new StatisticRow(10, true) { Title = BeatmapsetsStrings.ShowStatsStars }, + }, + }; + break; + + case 2: + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Children = new[] + { + FirstValue = new StatisticRow + { + Width = 0.5f, + Padding = new MarginPadding { Right = 5, Vertical = 2.5f }, + }, // circle size/key amount + HpDrain = new StatisticRow + { + Title = BeatmapsetsStrings.ShowStatsDrain, + Width = 0.5f, + Padding = new MarginPadding { Left = 5, Vertical = 2.5f }, + }, + Accuracy = new StatisticRow + { + Title = BeatmapsetsStrings.ShowStatsAccuracy, + Width = 0.5f, + Padding = new MarginPadding { Right = 5, Vertical = 2.5f }, + }, + ApproachRate = new StatisticRow + { + Title = BeatmapsetsStrings.ShowStatsAr, + Width = 0.5f, + Padding = new MarginPadding { Left = 5, Vertical = 2.5f }, + }, + starDifficulty = new StatisticRow(10, true) + { + Title = BeatmapsetsStrings.ShowStatsStars, + Width = 0.5f, + Padding = new MarginPadding { Right = 5, Vertical = 2.5f }, + }, + }, + }; + break; + } } [BackgroundDependencyLoader] @@ -118,21 +169,44 @@ namespace osu.Game.Screens.Select.Details IBeatmapDifficultyInfo baseDifficulty = BeatmapInfo?.Difficulty; BeatmapDifficulty adjustedDifficulty = null; - if (baseDifficulty != null && mods.Value.Any(m => m is IApplicableToDifficulty)) + IRulesetInfo ruleset = gameRuleset?.Value ?? beatmapInfo.Ruleset; + + if (baseDifficulty != null) { - adjustedDifficulty = new BeatmapDifficulty(baseDifficulty); + BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(baseDifficulty); foreach (var mod in mods.Value.OfType()) - mod.ApplyToDifficulty(adjustedDifficulty); + mod.ApplyToDifficulty(originalDifficulty); + + adjustedDifficulty = originalDifficulty; + + if (gameRuleset != null) + { + double rate = 1; + foreach (var mod in mods.Value.OfType()) + rate = mod.ApplyToRate(0, rate); + + adjustedDifficulty = ruleset.CreateInstance().GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); + + TooltipContent = new AdjustedAttributesTooltip.Data(originalDifficulty, adjustedDifficulty); + } } - switch (BeatmapInfo?.Ruleset.OnlineID) + switch (ruleset.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 + // 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.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 = baseDifficulty == null ? 0 : legacyRuleset.GetKeyCount(BeatmapInfo); + FirstValue.Title = BeatmapsetsStrings.ShowStatsCsMania; - FirstValue.Value = (baseDifficulty?.CircleSize ?? 0, null); + FirstValue.Value = (keyCount, keyCount); + break; default: @@ -261,23 +335,36 @@ namespace osu.Game.Screens.Select.Details Font = OsuFont.GetFont(size: 12) }, }, - bar = new Bar + new Container { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - RelativeSizeAxes = Axes.X, - Height = 5, - BackgroundColour = Color4.White.Opacity(0.5f), - Padding = new MarginPadding { Left = name_width + 10, Right = value_width + 10 }, - }, - ModBar = new Bar - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - RelativeSizeAxes = Axes.X, - Alpha = 0.5f, - Height = 5, + RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Left = name_width + 10, Right = value_width + 10 }, + Children = new Drawable[] + { + new Container + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + Height = 5, + + CornerRadius = 2, + Masking = true, + Children = new Drawable[] + { + bar = new Bar + { + RelativeSizeAxes = Axes.Both, + BackgroundColour = Color4.White.Opacity(0.5f), + }, + ModBar = new Bar + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.5f, + }, + } + }, + } }, new Container { diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index c15bd76ef8..1827eb58ca 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -250,7 +250,7 @@ namespace osu.Game.Screens.Select protected override bool OnHover(HoverEvent e) => true; - private partial class FilterControlTextBox : SeekLimitedSearchTextBox + internal partial class FilterControlTextBox : SeekLimitedSearchTextBox { private const float filter_text_size = 12; diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 811f623ee5..a7c8e7d093 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -219,6 +219,44 @@ namespace osu.Game.Screens.Select public bool Equals(OptionalTextFilter other) => SearchTerm == other.SearchTerm; } + /// + /// Given a new filter criteria, decide whether a full sort needs to be performed. + /// + /// + /// + public bool RequiresSorting(FilterCriteria newCriteria) + { + if (Sort != newCriteria.Sort) + return true; + + switch (Sort) + { + // Some sorts are stable across all other changes. + // Running these sorts will sort all items, including currently hidden items. + case SortMode.Artist: + case SortMode.Author: + case SortMode.DateSubmitted: + case SortMode.DateAdded: + case SortMode.DateRanked: + case SortMode.Source: + case SortMode.Title: + return false; + + // Some sorts use aggregate max comparisons, which will change based on filtered items. + // These sorts generally ignore items hidden by filtered state, so we must force a sort under all circumstances here. + // + // This makes things very slow when typing a text search, and we probably want to consider a way to optimise things going forward. + case SortMode.LastPlayed: + case SortMode.BPM: + case SortMode.Length: + case SortMode.Difficulty: + return true; + + default: + throw new ArgumentOutOfRangeException(nameof(Sort), Sort, "Unknown sort mode"); + } + } + public enum MatchMode { /// diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index 9a84f9a0aa..69782c25bb 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -19,6 +19,7 @@ using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; using osu.Game.Input.Bindings; +using osu.Game.Utils; namespace osu.Game.Screens.Select { @@ -87,12 +88,11 @@ namespace osu.Game.Screens.Select private void updateMultiplierText() => Schedule(() => { double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1; + MultiplierText.Text = multiplier == 1 ? string.Empty : ModUtils.FormatScoreMultiplier(multiplier); - MultiplierText.Text = multiplier.Equals(1.0) ? string.Empty : $"{multiplier:N2}x"; - - if (multiplier > 1.0) + if (multiplier > 1) MultiplierText.FadeColour(highMultiplierColour, 200); - else if (multiplier < 1.0) + else if (multiplier < 1) MultiplierText.FadeColour(lowMultiplierColour, 200); else MultiplierText.FadeColour(Color4.White, 200); diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs index 5bcb4c27a7..e2e3404877 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs @@ -12,12 +12,12 @@ namespace osu.Game.Screens.Select.Leaderboards [Description("Local Ranking")] Local, - [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardCountry))] - Country, - [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardGlobal))] Global, + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardCountry))] + Country, + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardFriend))] Friend, } diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs index 8a1b9ef3e1..deb1100dfc 100644 --- a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs @@ -80,8 +80,8 @@ namespace osu.Game.Screens.Select protected override BeatmapDetailAreaTabItem[] CreateTabItems() => base.CreateTabItems().Concat(new BeatmapDetailAreaTabItem[] { new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local), - new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country), new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global), + new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country), new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend), }).ToArray(); @@ -95,12 +95,12 @@ namespace osu.Game.Screens.Select case TabType.Local: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local); - case TabType.Country: - return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country); - case TabType.Global: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global); + case TabType.Country: + return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country); + case TabType.Friends: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend); diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index 86bebdc2ff..4951504ff5 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -34,10 +35,10 @@ namespace osu.Game.Screens.Select public override bool AllowExternalScreenChange => true; - public override MenuItem[] CreateForwardNavigationMenuItemsForBeatmap(BeatmapInfo beatmap) => new MenuItem[] + public override MenuItem[] CreateForwardNavigationMenuItemsForBeatmap(Func getBeatmap) => new MenuItem[] { - new OsuMenuItem(ButtonSystemStrings.Play.ToSentence(), MenuItemType.Highlighted, () => FinaliseSelection(beatmap)), - new OsuMenuItem(ButtonSystemStrings.Edit.ToSentence(), MenuItemType.Standard, () => Edit(beatmap)) + new OsuMenuItem(ButtonSystemStrings.Play.ToSentence(), MenuItemType.Highlighted, () => FinaliseSelection(getBeatmap())), + new OsuMenuItem(ButtonSystemStrings.Edit.ToSentence(), MenuItemType.Standard, () => Edit(getBeatmap())) }; protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap(); @@ -145,6 +146,14 @@ namespace osu.Game.Screens.Select } } + public override void OnSuspending(ScreenTransitionEvent e) + { + // Scores will be refreshed on arriving at this screen. + // Clear them to avoid animation overload on returning to song select. + playBeatmapDetailArea.Leaderboard.ClearScores(); + base.OnSuspending(e); + } + public override void OnResuming(ScreenTransitionEvent e) { base.OnResuming(e); diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index dfea4e3794..bf1724995a 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -13,6 +13,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Bindings; @@ -35,6 +36,7 @@ using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Details; using osu.Game.Screens.Select.Options; using osu.Game.Skinning; using osuTK; @@ -45,7 +47,7 @@ namespace osu.Game.Screens.Select { public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler { - public static readonly float WEDGE_HEIGHT = 245; + public static readonly float WEDGE_HEIGHT = 200; protected const float BACKGROUND_BLUR = 20; private const float left_area_padding = 20; @@ -89,11 +91,11 @@ namespace osu.Game.Screens.Select /// Creates any "action" menu items for the provided beatmap (ie. "Select", "Play", "Edit"). /// These will always be placed at the top of the context menu, with common items added below them. /// - /// The beatmap to create items for. + /// The beatmap to create items for. /// The menu items. - public virtual MenuItem[] CreateForwardNavigationMenuItemsForBeatmap(BeatmapInfo beatmap) => new MenuItem[] + public virtual MenuItem[] CreateForwardNavigationMenuItemsForBeatmap(Func getBeatmap) => new MenuItem[] { - new OsuMenuItem(@"Select", MenuItemType.Highlighted, () => FinaliseSelection(beatmap)) + new OsuMenuItem(@"Select", MenuItemType.Highlighted, () => FinaliseSelection(getBeatmap())) }; [Resolved] @@ -132,6 +134,8 @@ namespace osu.Game.Screens.Select private IDisposable? modSelectOverlayRegistration; + private AdvancedStats advancedStats = null!; + [Resolved] private MusicController music { get; set; } = null!; @@ -162,7 +166,7 @@ namespace osu.Game.Screens.Select BleedBottom = Footer.HEIGHT, SelectionChanged = updateSelectedBeatmap, BeatmapSetsChanged = carouselBeatmapsLoaded, - FilterApplied = updateVisibleBeatmapCount, + FilterApplied = () => Scheduler.AddOnce(updateVisibleBeatmapCount), GetRecommendedBeatmap = s => recommender?.GetRecommendedBeatmap(s), }, c => carouselContainer.Child = c); @@ -235,7 +239,7 @@ namespace osu.Game.Screens.Select Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = left_area_padding }, + Padding = new MarginPadding { Top = 5 }, Children = new Drawable[] { new LeftSideInteractionContainer(() => Carousel.ScrollToSelected()) @@ -253,12 +257,48 @@ namespace osu.Game.Screens.Select }, }, new Container + { + RelativeSizeAxes = Axes.X, + Height = 90, + Padding = new MarginPadding(10) + { + Left = left_area_padding, + Right = left_area_padding * 2 + 5, + }, + Y = WEDGE_HEIGHT, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black.Opacity(0.3f), + }, + advancedStats = new AdvancedStats(2) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(10) + }, + } + }, + } + }, + new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Bottom = Footer.HEIGHT, - Top = WEDGE_HEIGHT, + Top = WEDGE_HEIGHT + 70, Left = left_area_padding, Right = left_area_padding * 2, }, @@ -620,7 +660,8 @@ namespace osu.Game.Screens.Select logo.Action = () => { - FinaliseSelection(); + if (this.IsCurrentScreen()) + FinaliseSelection(); return false; }; } @@ -797,6 +838,8 @@ namespace osu.Game.Screens.Select ModSelect.Beatmap = beatmap; + advancedStats.BeatmapInfo = beatmap.BeatmapInfo; + bool beatmapSelected = beatmap is not DummyWorkingBeatmap; if (beatmapSelected) @@ -843,7 +886,7 @@ namespace osu.Game.Screens.Select private void carouselBeatmapsLoaded() { bindBindables(); - updateVisibleBeatmapCount(); + Scheduler.AddOnce(updateVisibleBeatmapCount); Carousel.AllowSelection = true; @@ -877,7 +920,8 @@ namespace osu.Game.Screens.Select { // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 // but also in this case we want support for formatting a number within a string). - FilterControl.InformationalText = Carousel.CountDisplayed != 1 ? $"{Carousel.CountDisplayed:#,0} matches" : $"{Carousel.CountDisplayed:#,0} match"; + int carouselCountDisplayed = Carousel.CountDisplayed; + FilterControl.InformationalText = carouselCountDisplayed != 1 ? $"{carouselCountDisplayed:#,0} matches" : $"{carouselCountDisplayed:#,0} match"; } private bool boundLocalBindables; diff --git a/osu.Game/Skinning/ISerialisableDrawable.cs b/osu.Game/Skinning/ISerialisableDrawable.cs index 503b44c2dd..c9dcaca6d1 100644 --- a/osu.Game/Skinning/ISerialisableDrawable.cs +++ b/osu.Game/Skinning/ISerialisableDrawable.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Globalization; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; @@ -46,7 +47,7 @@ namespace osu.Game.Skinning if (!(target is IParseable parseable)) throw new InvalidOperationException($"Bindable type {target.GetType().ReadableName()} is not {nameof(IParseable)}."); - parseable.Parse(source); + parseable.Parse(source, CultureInfo.InvariantCulture); } } } diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 845fc77394..9c06cbbfb5 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -79,7 +79,14 @@ namespace osu.Game.Skinning marker.Position = fill.Position + new Vector2(fill.DrawWidth, isNewStyle ? fill.DrawHeight / 2 : 0); } - protected override void Flash() => marker.Flash(); + protected override void HealthChanged(bool increase) + { + if (increase) + marker.Bulge(); + base.HealthChanged(increase); + } + + protected override void Flash() => marker.Flash(Current.Value >= epic_cutoff); private static Texture getTexture(ISkin skin, string name) => skin?.GetTexture($"scorebar-{name}"); @@ -113,19 +120,16 @@ namespace osu.Game.Skinning Origin = Anchor.Centre, }; - protected override void LoadComplete() + protected override void Update() { - base.LoadComplete(); + base.Update(); - Current.BindValueChanged(hp => - { - if (hp.NewValue < 0.2f) - Main.Texture = superDangerTexture; - else if (hp.NewValue < epic_cutoff) - Main.Texture = dangerTexture; - else - Main.Texture = normalTexture; - }); + if (Current.Value < 0.2f) + Main.Texture = superDangerTexture; + else if (Current.Value < epic_cutoff) + Main.Texture = dangerTexture; + else + Main.Texture = normalTexture; } } @@ -226,37 +230,30 @@ namespace osu.Game.Skinning public abstract Sprite CreateSprite(); - protected override void LoadComplete() + public override void Flash(bool isEpic) { - base.LoadComplete(); - - Current.BindValueChanged(val => - { - if (val.NewValue > val.OldValue) - bulgeMain(); - }); - } - - public override void Flash() - { - bulgeMain(); - - bool isEpic = Current.Value >= epic_cutoff; - + Bulge(); explode.Blending = isEpic ? BlendingParameters.Additive : BlendingParameters.Inherit; explode.ScaleTo(1).Then().ScaleTo(isEpic ? 2 : 1.6f, 120); explode.FadeOutFromOne(120); } - private void bulgeMain() => + public override void Bulge() + { + base.Bulge(); Main.ScaleTo(1.4f).Then().ScaleTo(1, 200, Easing.Out); + } } public partial class LegacyHealthPiece : CompositeDrawable { public Bindable Current { get; } = new Bindable(); - public virtual void Flash() + public virtual void Bulge() + { + } + + public virtual void Flash(bool isEpic) { } } diff --git a/osu.Game/Skinning/LegacyJudgementPieceNew.cs b/osu.Game/Skinning/LegacyJudgementPieceNew.cs index 9b1ff9b22f..5ff28726c0 100644 --- a/osu.Game/Skinning/LegacyJudgementPieceNew.cs +++ b/osu.Game/Skinning/LegacyJudgementPieceNew.cs @@ -50,7 +50,7 @@ namespace osu.Game.Skinning }); } - if (result != HitResult.Miss) + if (!result.IsMiss()) { //new judgement shows old as a temporary effect AddInternal(temporaryOldStyle = new LegacyJudgementPieceOld(result, createMainDrawable, 1.05f, true) diff --git a/osu.Game/Skinning/LegacyJudgementPieceOld.cs b/osu.Game/Skinning/LegacyJudgementPieceOld.cs index 082d0e4a67..1834a17279 100644 --- a/osu.Game/Skinning/LegacyJudgementPieceOld.cs +++ b/osu.Game/Skinning/LegacyJudgementPieceOld.cs @@ -50,17 +50,34 @@ namespace osu.Game.Skinning // legacy judgements don't play any transforms if they are an animation.... UNLESS they are the temporary displayed judgement from new piece. if (animation?.FrameCount > 1 && !forceTransforms) - return; - - switch (result) { - case HitResult.Miss: + if (isMissedTick()) + applyMissedTickScaling(); + return; + } + + if (result.IsMiss()) + { + decimal? legacyVersion = skin.GetConfig(SkinConfiguration.LegacySetting.Version)?.Value; + + // missed ticks / slider end don't get the normal animation. + if (isMissedTick()) + { this.ScaleTo(1.6f); this.ScaleTo(1, 100, Easing.In); - decimal? legacyVersion = skin.GetConfig(SkinConfiguration.LegacySetting.Version)?.Value; + if (legacyVersion > 1.0m) + { + this.MoveTo(new Vector2(0, -2f)); + this.MoveToOffset(new Vector2(0, 10), fade_out_delay + fade_out_length, Easing.In); + } + } + else + { + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); - if (legacyVersion >= 2.0m) + if (legacyVersion > 1.0m) { this.MoveTo(new Vector2(0, -5)); this.MoveToOffset(new Vector2(0, 80), fade_out_delay + fade_out_length, Easing.In); @@ -71,21 +88,28 @@ namespace osu.Game.Skinning this.RotateTo(0); this.RotateTo(rotation, fade_in_length) .Then().RotateTo(rotation * 2, fade_out_delay + fade_out_length - fade_in_length, Easing.In); - break; - - default: - - this.ScaleTo(0.6f).Then() - .ScaleTo(1.1f, fade_in_length * 0.8f).Then() // t = 0.8 - .Delay(fade_in_length * 0.2f) // t = 1.0 - .ScaleTo(0.9f, fade_in_length * 0.2f).Then() // t = 1.2 - - // stable dictates scale of 0.9->1 over time 1.0 to 1.4, but we are already at 1.2. - // so we need to force the current value to be correct at 1.2 (0.95) then complete the - // second half of the transform. - .ScaleTo(0.95f).ScaleTo(finalScale, fade_in_length * 0.2f); // t = 1.4 - break; + } } + else + { + this.ScaleTo(0.6f).Then() + .ScaleTo(1.1f, fade_in_length * 0.8f).Then() // t = 0.8 + .Delay(fade_in_length * 0.2f) // t = 1.0 + .ScaleTo(0.9f, fade_in_length * 0.2f).Then() // t = 1.2 + + // stable dictates scale of 0.9->1 over time 1.0 to 1.4, but we are already at 1.2. + // so we need to force the current value to be correct at 1.2 (0.95) then complete the + // second half of the transform. + .ScaleTo(0.95f).ScaleTo(finalScale, fade_in_length * 0.2f); // t = 1.4 + } + } + + private bool isMissedTick() => result.IsMiss() && result != HitResult.Miss; + + private void applyMissedTickScaling() + { + this.ScaleTo(0.6f); + this.ScaleTo(0.3f, 100, Easing.In); } public Drawable GetAboveHitObjectsProxiedContent() => CreateProxy(); diff --git a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs index 9acb29a793..042836984a 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs @@ -21,6 +21,8 @@ namespace osu.Game.Skinning /// public const float DEFAULT_COLUMN_SIZE = 30 * POSITION_SCALE_FACTOR; + public const float DEFAULT_HIT_POSITION = (480 - 402) * POSITION_SCALE_FACTOR; + public readonly int Keys; public Dictionary CustomColours { get; } = new Dictionary(); @@ -35,7 +37,7 @@ namespace osu.Game.Skinning public readonly float[] ExplosionWidth; public readonly float[] HoldNoteLightWidth; - public float HitPosition = (480 - 402) * POSITION_SCALE_FACTOR; + public float HitPosition = DEFAULT_HIT_POSITION; public float LightPosition = (480 - 413) * POSITION_SCALE_FACTOR; public float ScorePosition = 300 * POSITION_SCALE_FACTOR; public bool ShowJudgementLine = true; diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index b472afb74f..ff6e7fc38e 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -155,7 +155,15 @@ namespace osu.Game.Skinning if (i >= output.Length) break; - output[i] = float.Parse(values[i], CultureInfo.InvariantCulture) * (applyScaleFactor ? LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR : 1); + if (!float.TryParse(values[i], NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedValue)) + // some skins may provide incorrect entries in array values. to match stable behaviour, read such entries as zero. + // see: https://github.com/ppy/osu/issues/26464, stable code: https://github.com/peppy/osu-stable-reference/blob/3ea48705eb67172c430371dcfc8a16a002ed0d3d/osu!/Graphics/Skinning/Components/Section.cs#L134-L137 + parsedValue = 0; + + if (applyScaleFactor) + parsedValue *= LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + + output[i] = parsedValue; } } } diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 2e91770919..8f0cd59b68 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; using System.Linq; using JetBrains.Annotations; @@ -330,7 +331,7 @@ namespace osu.Game.Skinning var bindable = new Bindable(); if (val != null) - bindable.Parse(val); + bindable.Parse(val, CultureInfo.InvariantCulture); return bindable; } } @@ -457,6 +458,12 @@ namespace osu.Game.Skinning case HitResult.Miss: return this.GetAnimation("hit0", true, false); + case HitResult.LargeTickMiss: + return this.GetAnimation("slidertickmiss", true, false); + + case HitResult.IgnoreMiss: + return this.GetAnimation("sliderendmiss", true, false); + case HitResult.Meh: return this.GetAnimation("hit50", true, false); diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index 8aefa50252..581e7534e4 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; @@ -44,10 +45,11 @@ namespace osu.Game.Skinning [BackgroundDependencyLoader] private void load(ISkinSource skin) { - base.Font = new FontUsage(skin.GetFontPrefix(font), 1, fixedWidth: FixedWidth); + string fontPrefix = skin.GetFontPrefix(font); + base.Font = new FontUsage(fontPrefix, 1, fixedWidth: FixedWidth); Spacing = new Vector2(-skin.GetFontOverlap(font), 0); - glyphStore = new LegacyGlyphStore(skin, MaxSizePerGlyph); + glyphStore = new LegacyGlyphStore(fontPrefix, skin, MaxSizePerGlyph); } protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); @@ -57,25 +59,42 @@ namespace osu.Game.Skinning private readonly ISkin skin; private readonly Vector2? maxSize; - public LegacyGlyphStore(ISkin skin, Vector2? maxSize) + private readonly string fontName; + + private readonly Dictionary cache = new Dictionary(); + + public LegacyGlyphStore(string fontName, ISkin skin, Vector2? maxSize) { + this.fontName = fontName; this.skin = skin; this.maxSize = maxSize; } public ITexturedCharacterGlyph? Get(string? fontName, char character) { + // We only service one font. + if (fontName != this.fontName) + return null; + + if (cache.TryGetValue(character, out var cached)) + return cached; + string lookup = getLookupName(character); var texture = skin.GetTexture($"{fontName}-{lookup}"); - if (texture == null) - return null; + TexturedCharacterGlyph? glyph = null; - if (maxSize != null) - texture = texture.WithMaximumSize(maxSize.Value); + if (texture != null) + { + if (maxSize != null) + texture = texture.WithMaximumSize(maxSize.Value); - return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 1f / texture.ScaleAdjust); + glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 1f / texture.ScaleAdjust); + } + + cache[character] = glyph; + return glyph; } private static string getLookupName(char character) diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index f866a4f8ec..f153f4f8d3 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -194,9 +194,33 @@ namespace osu.Game.Skinning /// /// Whether any samples are currently playing. /// - public bool IsPlaying => samplesContainer.Any(s => s.Playing); + public bool IsPlaying + { + get + { + foreach (PoolableSkinnableSample s in samplesContainer) + { + if (s.Playing) + return true; + } - public bool IsPlayed => samplesContainer.Any(s => s.Played); + return false; + } + } + + public bool IsPlayed + { + get + { + foreach (PoolableSkinnableSample s in samplesContainer) + { + if (s.Played) + return true; + } + + return false; + } + } public IBindable AggregateVolume => samplesContainer.AggregateVolume; diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index bb4e06654a..1f491be7e3 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -132,8 +132,8 @@ namespace osu.Game.Tests.Beatmaps public AudioManager AudioManager => Audio; public IResourceStore Files => userSkinResourceStore; public new IResourceStore Resources => base.Resources; - public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => null; - RealmAccess IStorageResourceProvider.RealmAccess => null; + public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => null!; + RealmAccess IStorageResourceProvider.RealmAccess => null!; #endregion diff --git a/osu.Game/Tests/Visual/ModPerfectTestScene.cs b/osu.Game/Tests/Visual/ModFailConditionTestScene.cs similarity index 72% rename from osu.Game/Tests/Visual/ModPerfectTestScene.cs rename to osu.Game/Tests/Visual/ModFailConditionTestScene.cs index 164faa16aa..8f0dff055d 100644 --- a/osu.Game/Tests/Visual/ModPerfectTestScene.cs +++ b/osu.Game/Tests/Visual/ModFailConditionTestScene.cs @@ -8,11 +8,11 @@ using osu.Game.Rulesets.Objects; namespace osu.Game.Tests.Visual { - public abstract partial class ModPerfectTestScene : ModTestScene + public abstract partial class ModFailConditionTestScene : ModTestScene { - private readonly ModPerfect mod; + private readonly ModFailCondition mod; - protected ModPerfectTestScene(ModPerfect mod) + protected ModFailConditionTestScene(ModFailCondition mod) { this.mod = mod; } @@ -26,15 +26,15 @@ namespace osu.Game.Tests.Visual HitObjects = { testData.HitObject } }, Autoplay = !shouldMiss, - PassCondition = () => ((PerfectModTestPlayer)Player).CheckFailed(shouldMiss && testData.FailOnMiss) + PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(shouldMiss && testData.FailOnMiss) }); - protected override TestPlayer CreateModPlayer(Ruleset ruleset) => new PerfectModTestPlayer(); + protected override TestPlayer CreateModPlayer(Ruleset ruleset) => new ModFailConditionTestPlayer(CurrentTestData, AllowFail); - private partial class PerfectModTestPlayer : TestPlayer + protected partial class ModFailConditionTestPlayer : ModTestPlayer { - public PerfectModTestPlayer() - : base(showResults: false) + public ModFailConditionTestPlayer(ModTestData data, bool allowFail) + : base(data, allowFail) { } diff --git a/osu.Game/Tests/Visual/ModTestScene.cs b/osu.Game/Tests/Visual/ModTestScene.cs index aa5b506343..c2ebcdefac 100644 --- a/osu.Game/Tests/Visual/ModTestScene.cs +++ b/osu.Game/Tests/Visual/ModTestScene.cs @@ -20,35 +20,35 @@ namespace osu.Game.Tests.Visual { protected sealed override bool HasCustomSteps => true; - private ModTestData currentTestData; + protected ModTestData CurrentTestData { get; private set; } protected void CreateModTest(ModTestData testData) => CreateTest(() => { - AddStep("set test data", () => currentTestData = testData); + AddStep("set test data", () => CurrentTestData = testData); }); public override void TearDownSteps() { AddUntilStep("test passed", () => { - if (currentTestData == null) + if (CurrentTestData == null) return true; - return currentTestData.PassCondition?.Invoke() ?? false; + return CurrentTestData.PassCondition?.Invoke() ?? false; }); base.TearDownSteps(); } - protected sealed override IBeatmap CreateBeatmap(RulesetInfo ruleset) => currentTestData?.Beatmap ?? base.CreateBeatmap(ruleset); + protected sealed override IBeatmap CreateBeatmap(RulesetInfo ruleset) => CurrentTestData?.Beatmap ?? base.CreateBeatmap(ruleset); protected sealed override TestPlayer CreatePlayer(Ruleset ruleset) { var mods = new List(SelectedMods.Value); - if (currentTestData.Mods != null) - mods.AddRange(currentTestData.Mods); - if (currentTestData.Autoplay) + if (CurrentTestData.Mods != null) + mods.AddRange(CurrentTestData.Mods); + if (CurrentTestData.Autoplay) mods.Add(ruleset.GetAutoplayMod()); SelectedMods.Value = mods; @@ -56,7 +56,7 @@ namespace osu.Game.Tests.Visual return CreateModPlayer(ruleset); } - protected virtual TestPlayer CreateModPlayer(Ruleset ruleset) => new ModTestPlayer(currentTestData, AllowFail); + protected virtual TestPlayer CreateModPlayer(Ruleset ruleset) => new ModTestPlayer(CurrentTestData, AllowFail); protected partial class ModTestPlayer : TestPlayer { diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index 94be4a375d..947305439e 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -58,6 +58,12 @@ namespace osu.Game.Tests.Visual [SetUpSteps] public virtual void SetUpSteps() + { + CreateNewGame(); + ConfirmAtMainMenu(); + } + + protected void CreateNewGame() { AddStep("Create new game instance", () => { @@ -71,8 +77,6 @@ namespace osu.Game.Tests.Visual AddUntilStep("Wait for load", () => Game.IsLoaded); AddUntilStep("Wait for intro", () => Game.ScreenStack.CurrentScreen is IntroScreen); - - ConfirmAtMainMenu(); } [TearDownSteps] diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 273faf9bd1..b6a77e754d 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -13,6 +13,7 @@ using osu.Game.Overlays; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; +using osu.Framework.Screens; using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -20,6 +21,8 @@ using osu.Game.Online.Chat; using osu.Game.Resources.Localisation.Web; using osu.Game.Localisation; using osu.Game.Online.Multiplayer; +using osu.Game.Screens; +using osu.Game.Screens.Play; namespace osu.Game.Users { @@ -60,6 +63,9 @@ namespace osu.Game.Users [Resolved] protected OverlayColourProvider? ColourProvider { get; private set; } + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + [Resolved] protected OsuColour Colours { get; private set; } = null!; @@ -113,23 +119,26 @@ namespace osu.Game.Users new OsuMenuItem(ContextMenuStrings.ViewProfile, MenuItemType.Highlighted, ViewProfile) }; - if (!User.Equals(api.LocalUser.Value)) - { - items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, () => - { - channelManager?.OpenPrivateChannel(User); - chatOverlay?.Show(); - })); - } + if (User.Equals(api.LocalUser.Value)) + return items.ToArray(); - if ( - // TODO: uncomment this once lazer / osu-web is updating online states - // User.IsOnline && - multiplayerClient?.Room != null && - multiplayerClient.Room.Users.All(u => u.UserID != User.Id) - ) + items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, () => { - items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () => multiplayerClient.InvitePlayer(User.Id))); + channelManager?.OpenPrivateChannel(User); + chatOverlay?.Show(); + })); + + if (User.IsOnline) + { + items.Add(new OsuMenuItem(ContextMenuStrings.SpectatePlayer, MenuItemType.Standard, () => + { + performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))); + })); + + if (multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true) + { + items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () => multiplayerClient.InvitePlayer(User.Id))); + } } return items.ToArray(); diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index 1bd60fcdde..252579a186 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -226,5 +228,21 @@ namespace osu.Game.Utils return proposedWereValid; } + + /// + /// Given a value of a score multiplier, returns a string version with special handling for a value near 1.00x. + /// + /// The value of the score multiplier. + /// A formatted score multiplier with a trailing "x" symbol + public static LocalisableString FormatScoreMultiplier(double scoreMultiplier) + { + // Round multiplier values away from 1.00x to two significant digits. + if (scoreMultiplier > 1) + scoreMultiplier = Math.Ceiling(Math.Round(scoreMultiplier * 100, 12)) / 100; + else + scoreMultiplier = Math.Floor(Math.Round(scoreMultiplier * 100, 12)) / 100; + + return scoreMultiplier.ToLocalisableString("0.00x"); + } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index cc100605b4..a4aef97196 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,8 +36,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index a6b3527466..9afeaf7338 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -23,6 +23,6 @@ iossimulator-x64 - +